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
const copied = ref(false)
// Tab state for QR display (ndebit vs LNURL-withdraw)
const activeTab = ref<'ndebit' | 'lnurl'>('ndebit')
// QR display mode: unified (default), lnurl-only, or clink-only
const qrMode = ref<'unified' | 'lnurl' | 'clink'>('unified')
const lnurlWithdraw = ref<string | null>(null)
const isGeneratingLnurl = ref(false)
// Switch to LNURL tab and generate LNURL-withdraw if needed
async function switchToLnurl() {
activeTab.value = 'lnurl'
if (!lnurlWithdraw.value && !isGeneratingLnurl.value) {
isGeneratingLnurl.value = true
try {
lnurlWithdraw.value = await atmStore.generateLnurlWithdraw()
} catch (err) {
console.error('[CashIn] Failed to generate LNURL-withdraw:', err)
} finally {
isGeneratingLnurl.value = false
// Generate LNURL-withdraw when entering displayingQR state
watch(
() => atmStore.nestedState,
async (state) => {
if (state === 'displayingQR' && !lnurlWithdraw.value && !isGeneratingLnurl.value) {
isGeneratingLnurl.value = true
try {
lnurlWithdraw.value = await atmStore.generateLnurlWithdraw()
} catch (err) {
console.error('[CashIn] Failed to generate LNURL-withdraw:', err)
} finally {
isGeneratingLnurl.value = false
}
}
}
}
},
{ immediate: true }
)
// Copy ndebit URI to clipboard
async function copyNdebit() {
const uri = atmStore.context?.ndebitUri
// Compute the unified QR value (lightning + clink on separate lines)
const unifiedQrValue = computed(() => {
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
try {
@ -223,94 +254,103 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</CardContent>
</Card>
<!-- Display QR (ndebit or LNURL-withdraw) -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-[480px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Scan to Receive Sats</CardTitle>
<CardDescription v-if="context">
<Badge variant="secondary" class="text-lg">
{{ formatSats(context.satsAmount) }} sats
</Badge>
</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
<!-- Display QR (unified lightning + clink) -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-[420px] border-0 bg-white/5">
<CardContent class="pt-6 space-y-5">
<!-- Amount Display -->
<div class="text-center">
<p class="text-sm text-muted-foreground">Withdraw</p>
<p class="text-3xl font-bold text-orange-400">
{{ formatSats(context?.satsAmount || 0) }} sats
</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>
<p class="text-sm text-muted-foreground">
${{ ((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
: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>
<p class="text-center text-xs text-muted-foreground">
Or copy and paste into your wallet
</p>
</div>
</div>
<!-- LNURL-withdraw QR Code (shown when lnurl tab is active) -->
<div v-else-if="activeTab === 'lnurl'" class="flex flex-col items-center">
<Skeleton v-if="isGeneratingLnurl" class="h-[220px] w-[220px]" />
<QRCode v-else-if="lnurlWithdraw" :value="lnurlWithdraw" :size="220" />
<div
v-else
class="flex h-[220px] w-[220px] items-center justify-center rounded-lg bg-white/10"
>
<p class="text-sm text-muted-foreground">Failed to generate LNURL</p>
</div>
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet (Phoenix, Zeus, Wallet of Satoshi, etc.)
<!-- Mode label + truncated URI -->
<div class="text-center space-y-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{{
qrMode === 'unified'
? 'Universal'
: qrMode === 'lnurl'
? 'LNURL-withdraw'
: 'CLINK ndebit'
}}
</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>
<!-- 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) -->
<details class="rounded-lg bg-white/5 p-3">
<summary class="cursor-pointer text-sm text-muted-foreground">