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:
parent
d650647d8f
commit
30a2eb2199
1 changed files with 137 additions and 97 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue