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,29 +22,60 @@ 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) => {
isGeneratingLnurl.value = true if (state === 'displayingQR' && !lnurlWithdraw.value && !isGeneratingLnurl.value) {
try { isGeneratingLnurl.value = true
lnurlWithdraw.value = await atmStore.generateLnurlWithdraw() try {
} catch (err) { lnurlWithdraw.value = await atmStore.generateLnurlWithdraw()
console.error('[CashIn] Failed to generate LNURL-withdraw:', err) } catch (err) {
} finally { console.error('[CashIn] Failed to generate LNURL-withdraw:', err)
isGeneratingLnurl.value = false } finally {
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,94 +254,103 @@ 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>
<!-- Tab buttons for switching between ndebit and LNURL -->
<div class="flex gap-2 mt-3 justify-center">
<Button
:variant="activeTab === 'ndebit' ? 'default' : 'outline'"
size="sm"
@click="activeTab = 'ndebit'"
>
CLINK (Shock Wallet)
</Button>
<Button
:variant="activeTab === 'lnurl' ? 'default' : 'outline'"
size="sm"
@click="switchToLnurl"
>
LNURL (Any Wallet)
</Button>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- NDebit QR Code (shown when ndebit tab is active) -->
<div v-if="activeTab === 'ndebit'" class="flex flex-col items-center">
<QRCode v-if="context?.ndebitUri" :value="context.ndebitUri" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with Shock Wallet or compatible CLINK wallet
</p> </p>
<!-- Copyable NDebit URI --> <p class="text-sm text-muted-foreground">
<div v-if="context?.ndebitUri" class="mt-4 w-full space-y-2"> ${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
<div class="flex gap-2"> </p>
<Input :model-value="context.ndebitUri" readonly class="flex-1 font-mono text-xs" /> </div>
<Button variant="outline" size="sm" @click="copyNdebit">
{{ copied ? '✓ Copied' : 'Copy' }} <!-- Scan with selector -->
</Button> <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
:variant="qrMode === 'unified' ? 'default' : 'outline'"
size="sm"
class="rounded-full px-4"
@click="qrMode = 'unified'"
>
Universal
</Button>
<Button
:variant="qrMode === 'lnurl' ? 'default' : 'outline'"
size="sm"
class="rounded-full px-4"
@click="qrMode = 'lnurl'"
>
LNURL
</Button>
<Button
:variant="qrMode === 'clink' ? 'default' : 'outline'"
size="sm"
class="rounded-full px-4"
@click="qrMode = 'clink'"
>
CLINK
</Button>
</div>
</div>
<!-- QR Code -->
<div class="flex flex-col items-center">
<div class="relative">
<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
v-else
class="flex h-[240px] w-[240px] items-center justify-center rounded-xl bg-white/10"
>
<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>
<p class="text-center text-xs text-muted-foreground">
Or copy and paste into your wallet
</p>
</div> </div>
</div> </div>
<!-- LNURL-withdraw QR Code (shown when lnurl tab is active) --> <!-- Mode label + truncated URI -->
<div v-else-if="activeTab === 'lnurl'" class="flex flex-col items-center"> <div class="text-center space-y-1">
<Skeleton v-if="isGeneratingLnurl" class="h-[220px] w-[220px]" /> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<QRCode v-else-if="lnurlWithdraw" :value="lnurlWithdraw" :size="220" /> {{
<div qrMode === 'unified'
v-else ? 'Universal'
class="flex h-[220px] w-[220px] items-center justify-center rounded-lg bg-white/10" : qrMode === 'lnurl'
> ? 'LNURL-withdraw'
<p class="text-sm text-muted-foreground">Failed to generate LNURL</p> : 'CLINK ndebit'
</div> }}
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet (Phoenix, Zeus, Wallet of Satoshi, etc.)
</p> </p>
<div v-if="currentQrValue" class="flex items-center justify-center gap-2">
<code class="text-xs text-muted-foreground truncate max-w-[280px]">
{{ currentQrValue.split('\n')[0].slice(0, 32) }}...
</code>
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
<span v-if="copied">✓</span>
<span v-else class="text-muted-foreground">📋</span>
</Button>
</div>
</div> </div>
<!-- Instructions (different based on active tab) -->
<Alert class="border-green-500/50 bg-green-500/10">
<AlertDescription>
<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">
<li>1. Scan the QR code with your CLINK-compatible wallet</li>
<li>
2. Your wallet creates an invoice for
{{ formatSats(context?.satsAmount || 0) }} sats
</li>
<li>3. Confirm the claim in your wallet</li>
<li>4. Receive your sats instantly!</li>
</ol>
<ol v-else class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with any Lightning wallet</li>
<li>
2. Your wallet shows a withdrawal for
{{ formatSats(context?.satsAmount || 0) }} sats
</li>
<li>3. Confirm the withdrawal in your wallet</li>
<li>4. Sats arrive in your wallet!</li>
</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">
<summary class="cursor-pointer text-sm text-muted-foreground"> <summary class="cursor-pointer text-sm text-muted-foreground">