feat(machine): unified QR code with LNURL + CLINK payment options

Redesign cash-in QR display with cleaner UI inspired by payment gateways:
- Unified QR encodes both lightning:LNURL and clink:ndebit on separate lines
- Pill buttons to select Universal/LNURL/CLINK mode
- Generate LNURL-withdraw immediately when entering QR state
- QR code with lightning bolt overlay
- Truncated URI with copy button
- Amount display with sats and fiat

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-14 16:29:26 -05:00
commit 30a2eb2199

View file

@ -22,15 +22,16 @@ const invoiceInput = ref('')
// Copy state for ndebit URI // Copy state for ndebit URI
const copied = ref(false) const copied = ref(false)
// Tab state for QR display (ndebit vs LNURL-withdraw) // QR display mode: unified (default), lnurl-only, or clink-only
const activeTab = ref<'ndebit' | 'lnurl'>('ndebit') const qrMode = ref<'unified' | 'lnurl' | 'clink'>('unified')
const lnurlWithdraw = ref<string | null>(null) const lnurlWithdraw = ref<string | null>(null)
const isGeneratingLnurl = ref(false) const isGeneratingLnurl = ref(false)
// Switch to LNURL tab and generate LNURL-withdraw if needed // Generate LNURL-withdraw when entering displayingQR state
async function switchToLnurl() { watch(
activeTab.value = 'lnurl' () => atmStore.nestedState,
if (!lnurlWithdraw.value && !isGeneratingLnurl.value) { async (state) => {
if (state === 'displayingQR' && !lnurlWithdraw.value && !isGeneratingLnurl.value) {
isGeneratingLnurl.value = true isGeneratingLnurl.value = true
try { try {
lnurlWithdraw.value = await atmStore.generateLnurlWithdraw() lnurlWithdraw.value = await atmStore.generateLnurlWithdraw()
@ -40,11 +41,41 @@ async function switchToLnurl() {
isGeneratingLnurl.value = false isGeneratingLnurl.value = false
} }
} }
} },
{ immediate: true }
)
// Copy ndebit URI to clipboard // Compute the unified QR value (lightning + clink on separate lines)
async function copyNdebit() { const unifiedQrValue = computed(() => {
const uri = atmStore.context?.ndebitUri const ndebit = atmStore.context?.ndebitUri
const lnurl = lnurlWithdraw.value
if (lnurl && ndebit) {
// Unified: lightning scheme first (more universal), then clink
return `lightning:${lnurl}\n${ndebit}`
} else if (lnurl) {
return `lightning:${lnurl}`
} else if (ndebit) {
return ndebit
}
return null
})
// Get the appropriate QR value based on mode
const currentQrValue = computed(() => {
switch (qrMode.value) {
case 'unified':
return unifiedQrValue.value
case 'lnurl':
return lnurlWithdraw.value ? `lightning:${lnurlWithdraw.value}` : null
case 'clink':
return atmStore.context?.ndebitUri || null
}
})
// Copy current QR value to clipboard
async function copyQrValue() {
const uri = currentQrValue.value
if (!uri) return if (!uri) return
try { try {
@ -223,93 +254,102 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</CardContent> </CardContent>
</Card> </Card>
<!-- Display QR (ndebit or LNURL-withdraw) --> <!-- Display QR (unified lightning + clink) -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-[480px] border-0 bg-white/5"> <Card v-else-if="nestedState === 'displayingQR'" class="w-[420px] border-0 bg-white/5">
<CardHeader class="text-center"> <CardContent class="pt-6 space-y-5">
<CardTitle>Scan to Receive Sats</CardTitle> <!-- Amount Display -->
<CardDescription v-if="context"> <div class="text-center">
<Badge variant="secondary" class="text-lg"> <p class="text-sm text-muted-foreground">Withdraw</p>
{{ formatSats(context.satsAmount) }} sats <p class="text-3xl font-bold text-orange-400">
</Badge> {{ formatSats(context?.satsAmount || 0) }} sats
</CardDescription> </p>
<!-- Tab buttons for switching between ndebit and LNURL --> <p class="text-sm text-muted-foreground">
<div class="flex gap-2 mt-3 justify-center"> ${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
</p>
</div>
<!-- Scan with selector -->
<div class="text-center">
<p class="text-xs text-muted-foreground mb-2">Scan with</p>
<div class="flex flex-wrap gap-2 justify-center">
<Button <Button
:variant="activeTab === 'ndebit' ? 'default' : 'outline'" :variant="qrMode === 'unified' ? 'default' : 'outline'"
size="sm" size="sm"
@click="activeTab = 'ndebit'" class="rounded-full px-4"
@click="qrMode = 'unified'"
> >
CLINK (Shock Wallet) Universal
</Button> </Button>
<Button <Button
:variant="activeTab === 'lnurl' ? 'default' : 'outline'" :variant="qrMode === 'lnurl' ? 'default' : 'outline'"
size="sm" size="sm"
@click="switchToLnurl" class="rounded-full px-4"
@click="qrMode = 'lnurl'"
> >
LNURL (Any Wallet) LNURL
</Button> </Button>
</div> <Button
</CardHeader> :variant="qrMode === 'clink' ? 'default' : 'outline'"
<CardContent class="space-y-6"> size="sm"
<!-- NDebit QR Code (shown when ndebit tab is active) --> class="rounded-full px-4"
<div v-if="activeTab === 'ndebit'" class="flex flex-col items-center"> @click="qrMode = 'clink'"
<QRCode v-if="context?.ndebitUri" :value="context.ndebitUri" :size="220" /> >
<p class="mt-4 text-center text-sm text-muted-foreground"> CLINK
Scan with Shock Wallet or compatible CLINK wallet
</p>
<!-- Copyable NDebit URI -->
<div v-if="context?.ndebitUri" class="mt-4 w-full space-y-2">
<div class="flex gap-2">
<Input :model-value="context.ndebitUri" readonly class="flex-1 font-mono text-xs" />
<Button variant="outline" size="sm" @click="copyNdebit">
{{ copied ? '✓ Copied' : 'Copy' }}
</Button> </Button>
</div> </div>
<p class="text-center text-xs text-muted-foreground">
Or copy and paste into your wallet
</p>
</div>
</div> </div>
<!-- LNURL-withdraw QR Code (shown when lnurl tab is active) --> <!-- QR Code -->
<div v-else-if="activeTab === 'lnurl'" class="flex flex-col items-center"> <div class="flex flex-col items-center">
<Skeleton v-if="isGeneratingLnurl" class="h-[220px] w-[220px]" /> <div class="relative">
<QRCode v-else-if="lnurlWithdraw" :value="lnurlWithdraw" :size="220" /> <Skeleton
v-if="isGeneratingLnurl && !context?.ndebitUri"
class="h-[240px] w-[240px] rounded-xl"
/>
<div v-else-if="currentQrValue" class="rounded-xl overflow-hidden bg-white p-3">
<QRCode :value="currentQrValue" :size="216" />
</div>
<div <div
v-else v-else
class="flex h-[220px] w-[220px] items-center justify-center rounded-lg bg-white/10" class="flex h-[240px] w-[240px] items-center justify-center rounded-xl bg-white/10"
> >
<p class="text-sm text-muted-foreground">Failed to generate LNURL</p> <p class="text-sm text-muted-foreground">Generating...</p>
</div>
<!-- Lightning bolt overlay -->
<div
v-if="currentQrValue"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div
class="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center shadow-lg"
>
<span class="text-2xl">⚡</span>
</div>
</div>
</div> </div>
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet (Phoenix, Zeus, Wallet of Satoshi, etc.)
</p>
</div> </div>
<!-- Instructions (different based on active tab) --> <!-- Mode label + truncated URI -->
<Alert class="border-green-500/50 bg-green-500/10"> <div class="text-center space-y-1">
<AlertDescription> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<p class="mb-2 text-sm font-medium text-green-400">How it works:</p> {{
<ol v-if="activeTab === 'ndebit'" class="space-y-1 text-xs text-muted-foreground"> qrMode === 'unified'
<li>1. Scan the QR code with your CLINK-compatible wallet</li> ? 'Universal'
<li> : qrMode === 'lnurl'
2. Your wallet creates an invoice for ? 'LNURL-withdraw'
{{ formatSats(context?.satsAmount || 0) }} sats : 'CLINK ndebit'
</li> }}
<li>3. Confirm the claim in your wallet</li> </p>
<li>4. Receive your sats instantly!</li> <div v-if="currentQrValue" class="flex items-center justify-center gap-2">
</ol> <code class="text-xs text-muted-foreground truncate max-w-[280px]">
<ol v-else class="space-y-1 text-xs text-muted-foreground"> {{ currentQrValue.split('\n')[0].slice(0, 32) }}...
<li>1. Scan the QR code with any Lightning wallet</li> </code>
<li> <Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
2. Your wallet shows a withdrawal for <span v-if="copied">✓</span>
{{ formatSats(context?.satsAmount || 0) }} sats <span v-else class="text-muted-foreground">📋</span>
</li> </Button>
<li>3. Confirm the withdrawal in your wallet</li> </div>
<li>4. Sats arrive in your wallet!</li> </div>
</ol>
</AlertDescription>
</Alert>
<!-- Alternative: Manual Options (collapsed) --> <!-- Alternative: Manual Options (collapsed) -->
<details class="rounded-lg bg-white/5 p-3"> <details class="rounded-lg bg-white/5 p-3">