feat: update CashOutView UI for noffer-based flow

- Redesign cash-out view for noffer (CLINK Offers) workflow
- Add states: generatingNoffer, displayingNoffer, validatingRequest,
  sendingInvoice, awaitingPayment
- Remove old selectingAmount state (user specifies amount in wallet)
- Add debug controls to simulate offer requests for testing
- Wire up offer request callback in ATM store to state machine

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-25 16:54:48 -05:00
commit 66a3c054e7
2 changed files with 124 additions and 39 deletions

View file

@ -156,10 +156,23 @@ export const useAtmStore = defineStore('atm', () => {
paymentReceived(preimage) paymentReceived(preimage)
}) })
// Wire up offer request callback (for logging/debugging) // Wire up offer request callback - send to state machine for cash-out flow
services.onOfferRequest((request, sender) => { services.onOfferRequest((request, sender) => {
console.log('[ATM] Offer request from:', sender.slice(0, 16) + '...') console.log('[ATM] Offer request from:', sender.slice(0, 16) + '...')
console.log('[ATM] Amount requested:', request.amount_sats, 'sats') console.log('[ATM] Amount requested:', request.amount_sats, 'sats')
// Send to state machine if we're in the displayingNoffer state
if (nestedState.value === 'displayingNoffer') {
send({
type: 'OFFER_REQUEST_RECEIVED',
request: {
eventId: request.offer || 'unknown',
payerPubkey: sender,
amountSats: request.amount_sats,
description: request.description,
},
})
}
}) })
// Initialize state machine with real services // Initialize state machine with real services

View file

@ -25,13 +25,6 @@ watch(
const nestedState = computed(() => atmStore.nestedState) const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context) const context = computed(() => atmStore.context)
// Available denominations for cash-out
const amounts = [20, 40, 60, 80, 100]
function selectAmount(amount: number) {
atmStore.selectAmount(amount)
}
function cancel() { function cancel() {
atmStore.cancel() atmStore.cancel()
} }
@ -47,6 +40,10 @@ function skipReceipt() {
function formatSats(sats: number): string { function formatSats(sats: number): string {
return sats.toLocaleString() return sats.toLocaleString()
} }
function formatFiat(cents: number): string {
return (cents / 100).toFixed(2)
}
</script> </script>
<template> <template>
@ -68,56 +65,131 @@ function formatSats(sats: number): string {
</CardContent> </CardContent>
</Card> </Card>
<!-- Select Amount --> <!-- Generating Noffer -->
<Card v-else-if="nestedState === 'selectingAmount'" class="border-0 bg-white/5"> <Card v-else-if="nestedState === 'generatingNoffer'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Preparing payment code...</p>
</CardContent>
</Card>
<!-- Display Noffer QR - Main cash-out screen -->
<Card v-else-if="nestedState === 'displayingNoffer'" class="w-[480px] border-0 bg-white/5">
<CardHeader class="text-center"> <CardHeader class="text-center">
<CardTitle>Select Amount</CardTitle> <CardTitle>Scan to Sell Bitcoin</CardTitle>
<CardDescription v-if="context"> <CardDescription v-if="context">
Rate: {{ formatSats(context.exchangeRate) }} sats/$ Current rate:
<Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="space-y-6">
<div class="grid grid-cols-3 gap-4"> <!-- Noffer QR Code -->
<Card <div class="flex flex-col items-center">
v-for="amount in amounts" <QRCode v-if="context?.nofferString" :value="context.nofferString" :size="220" />
:key="amount" <p class="mt-4 text-center text-sm text-muted-foreground">
class="cursor-pointer border-2 border-transparent bg-white/5 transition hover:border-orange-500 hover:bg-white/10" Scan with your Lightning wallet
@click="selectAmount(amount)" </p>
>
<CardContent class="flex flex-col items-center p-4">
<span class="text-2xl font-bold">${{ amount }}</span>
<span v-if="context" class="mt-1 text-sm text-muted-foreground">
≈ {{ formatSats(Math.floor(amount * context.exchangeRate)) }} sats
</span>
</CardContent>
</Card>
</div> </div>
<!-- Instructions -->
<Alert class="border-orange-500/50 bg-orange-500/10">
<AlertDescription>
<p class="mb-2 text-sm font-medium text-orange-400">How it works:</p>
<ol class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with Shock Wallet or compatible wallet</li>
<li>2. Enter the amount you want to sell (in sats)</li>
<li>3. Confirm the payment in your wallet</li>
<li>4. Receive cash from the ATM</li>
</ol>
</AlertDescription>
</Alert>
<!-- Info about limits -->
<div class="rounded-lg bg-white/5 p-4 text-center">
<p class="text-sm text-muted-foreground">
Limits: <Badge variant="outline">$1 - $500</Badge>
</p>
<p class="mt-1 text-xs text-muted-foreground">Waiting for payment request...</p>
</div>
<!-- Debug: Simulate offer request -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
<AlertDescription class="text-center">
<p class="mb-2 text-xs text-red-400">Dev: Simulate offer request</p>
<Button
variant="outline"
size="sm"
@click="
atmStore.send({
type: 'OFFER_REQUEST_RECEIVED',
request: {
eventId: 'mock-event-' + Date.now(),
payerPubkey: 'mock-pubkey-' + Date.now(),
amountSats: 250000,
description: 'Mock cash out',
},
})
"
>
Simulate $100 Request (250k sats)
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
<!-- Validating Request -->
<Card v-else-if="nestedState === 'validatingRequest'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Validating amount...</p>
<p v-if="context" class="text-sm text-orange-400">
{{ formatSats(context.satsAmount) }} sats requested
</p>
</CardContent> </CardContent>
</Card> </Card>
<!-- Generating Invoice --> <!-- Generating Invoice -->
<Card <Card v-else-if="nestedState === 'generatingInvoice'" class="w-80 border-0 bg-white/5">
v-else-if="nestedState === 'calculatingInvoice' || nestedState === 'generatingInvoice'"
class="w-80 border-0 bg-white/5"
>
<CardContent class="flex flex-col items-center gap-4 p-8"> <CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" /> <Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Generating invoice...</p> <p class="text-muted-foreground">Creating invoice...</p>
</CardContent> </CardContent>
</Card> </Card>
<!-- Display Invoice QR --> <!-- Sending Invoice -->
<Card v-else-if="nestedState === 'displayingInvoice'" class="w-96 border-0 bg-white/5"> <Card v-else-if="nestedState === 'sendingInvoice'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Sending invoice to wallet...</p>
</CardContent>
</Card>
<!-- Awaiting Payment -->
<Card v-else-if="nestedState === 'awaitingPayment'" class="w-96 border-0 bg-white/5">
<CardHeader class="text-center"> <CardHeader class="text-center">
<CardTitle>Scan to Pay</CardTitle> <CardTitle>Waiting for Payment</CardTitle>
<CardDescription v-if="context"> <CardDescription v-if="context">
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
receive <Badge class="bg-green-600">${{ context.fiatAmount / 100 }}</Badge> receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<QRCode v-if="context?.invoice" :value="context.invoice" :size="200" /> <div class="flex flex-col items-center gap-4 py-8">
<p class="text-center text-sm text-muted-foreground">Waiting for payment...</p> <div class="h-16 w-16 animate-pulse rounded-full bg-orange-500/20">
<div class="flex h-full items-center justify-center text-3xl">⏳</div>
</div>
<p class="text-center text-muted-foreground">
Please confirm the payment in your wallet
</p>
</div>
<!-- Invoice info (collapsed) -->
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3 text-center">
<p class="truncate font-mono text-xs text-muted-foreground">
{{ context.invoice.slice(0, 40) }}...
</p>
</div>
<!-- Dev controls --> <!-- Dev controls -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10"> <Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
@ -150,7 +222,7 @@ function formatSats(sats: number): string {
<CardTitle class="text-green-400">Cash Ready!</CardTitle> <CardTitle class="text-green-400">Cash Ready!</CardTitle>
<p v-if="context"> <p v-if="context">
Please take your Please take your
<Badge class="bg-green-600 text-lg">${{ context.fiatAmount / 100 }}</Badge> <Badge class="bg-green-600 text-lg">${{ formatFiat(context.fiatAmount) }}</Badge>
</p> </p>
<Button <Button
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500" class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"