feat(machine): redesign cash-out with split-screen layout

Split-screen for displayingInvoice (info left, QR right), full-width
for selectingAmount. Add StepIndicator with warning flow accent header.
Replace Skeleton with BounceDots, remove ScrollArea and Card wrappers,
wrap states in Transition for fade animations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-01 12:18:46 -05:00
commit 3d9d85ba7f

View file

@ -3,17 +3,31 @@ import { watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
import BounceDots from '@/components/BounceDots.vue'
import StepIndicator from '@/components/StepIndicator.vue'
const router = useRouter() const router = useRouter()
const atmStore = useAtmStore() const atmStore = useAtmStore()
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
const cashOutSteps = ['Select', 'Pay', 'Collect']
const currentStepIndex = computed(() => {
switch (nestedState.value) {
case 'fetchingRate':
case 'selectingAmount':
return 0
case 'generatingInvoice':
case 'displayingInvoice':
return 1
default:
return 2
}
})
// Copy invoice functionality // Copy invoice functionality
const copyButtonText = ref('Copy Invoice') const copyButtonText = ref('Copy Invoice')
@ -99,66 +113,65 @@ function formatFiat(cents: number): string {
<template> <template>
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<!-- Header --> <!-- Header with warning flow accent -->
<header class="flex items-center bg-foreground/10 px-4 pb-3 pt-12 sm:px-8 sm:py-4"> <header
<Button variant="ghost" class="text-muted-foreground" @click="cancel">← Cancel</Button> class="flex items-center px-8 py-4"
<h2 class="flex-1 text-center text-lg font-semibold sm:text-xl">Sell Bitcoin</h2> style="background: color-mix(in srgb, var(--warning) 10%, var(--background))"
>
<Button variant="ghost" size="kiosk" class="text-muted-foreground" @click="cancel"
>← Cancel</Button
>
<div class="flex flex-1 flex-col items-center gap-2">
<h2 class="text-[2.5rem] font-semibold">Sell Bitcoin</h2>
<StepIndicator :steps="cashOutSteps" :current-step="currentStepIndex" />
</div>
<div class="w-20"></div> <div class="w-20"></div>
</header> </header>
<!-- Content --> <!-- Content area -->
<ScrollArea class="min-h-0 flex-1"> <main class="flex min-h-0 flex-1">
<main class="flex min-h-0 flex-1 items-start justify-center p-4 sm:items-center sm:p-8"> <Transition name="state-fade" mode="out-in">
<!-- Fetching Rate --> <!-- Fetching Rate (centered) -->
<Card v-if="nestedState === 'fetchingRate'" class="w-full max-w-80 border-0 bg-card"> <div
<CardContent class="flex flex-col items-center gap-4 p-8"> v-if="nestedState === 'fetchingRate'"
<Skeleton class="h-10 w-10 rounded-full" /> key="fetchingRate"
<p class="text-muted-foreground">Fetching exchange rate...</p> class="flex flex-1 flex-col items-center justify-center gap-6"
</CardContent> >
</Card> <BounceDots class="scale-150" />
<p class="text-2xl text-muted-foreground">Fetching exchange rate...</p>
</div>
<!-- Selecting Amount - Main cash-out screen --> <!-- Selecting Amount (full-width) -->
<Card <div
v-else-if="nestedState === 'selectingAmount'" v-else-if="nestedState === 'selectingAmount'"
class="w-full max-w-[520px] border-0 bg-card" key="selectingAmount"
class="flex flex-1 flex-col justify-center px-[8vw] py-6 gap-6"
> >
<CardHeader class="text-center"> <!-- Denomination grid -->
<CardTitle>Select Cash Amount</CardTitle> <div class="grid grid-cols-2 gap-6">
<CardDescription v-if="context">
Rate:
<Badge variant="secondary"
>{{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}</Badge
>
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Denomination buttons -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div <div
v-for="denom in availableDenominations" v-for="denom in availableDenominations"
:key="denom" :key="denom"
class="flex items-center justify-between rounded-lg bg-card px-4 py-3 sm:p-4" class="flex items-center justify-between rounded-lg bg-card px-6 py-5"
> >
<span class="text-xl font-bold text-success sm:text-2xl" <span class="text-[2.5rem] font-bold text-success"
>{{ atmStore.fiatSymbol }}{{ denom }}</span >{{ atmStore.fiatSymbol }}{{ denom }}</span
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="kiosk-icon"
class="h-9 w-9"
:disabled="!canRemove(denom)" :disabled="!canRemove(denom)"
@click="atmStore.removeDenomination(denom)" @click="atmStore.removeDenomination(denom)"
> >
- -
</Button> </Button>
<span class="w-8 text-center text-lg font-medium">{{ <span class="w-16 text-center text-[2rem] font-medium">{{
selectionCounts[denom] || 0 selectionCounts[denom] || 0
}}</span> }}</span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="kiosk-icon"
class="h-9 w-9"
:disabled="!canAdd(denom)" :disabled="!canAdd(denom)"
@click="atmStore.addDenomination(denom)" @click="atmStore.addDenomination(denom)"
> >
@ -168,24 +181,31 @@ function formatFiat(cents: number): string {
</div> </div>
</div> </div>
<!-- Selected amount summary --> <!-- Amount summary -->
<div class="rounded-lg bg-muted/50 p-6 text-center"> <div class="text-center py-4">
<p class="text-sm text-muted-foreground">You will receive</p> <p class="text-xl text-muted-foreground">You will receive</p>
<p class="text-4xl font-bold text-success"> <p class="text-[7vh] font-bold text-success leading-tight">
{{ atmStore.fiatSymbol }}{{ context ? formatFiat(context.fiatAmount) : '0.00' }} {{ atmStore.fiatSymbol }}{{ context ? formatFiat(context.fiatAmount) : '0.00' }}
</p> </p>
<p class="mt-2 text-sm text-muted-foreground"> <p class="mt-2 text-2xl text-muted-foreground">
You pay: You pay:
<Badge variant="secondary" class="ml-1"> <Badge variant="secondary" class="ml-1 text-2xl">
{{ context ? formatSats(context.satsAmount) : 0 }} sats {{ context ? formatSats(context.satsAmount) : 0 }} sats
</Badge> </Badge>
</p> </p>
<p v-if="context" class="mt-1 text-lg text-muted-foreground">
Rate:
<Badge variant="secondary" class="text-lg">
{{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}
</Badge>
</p>
</div> </div>
<!-- Action buttons --> <!-- Action buttons -->
<div class="flex gap-4"> <div class="flex gap-4">
<Button <Button
variant="outline" variant="outline"
size="kiosk-lg"
class="flex-1" class="flex-1"
:disabled="!context?.cashOutSelection?.length" :disabled="!context?.cashOutSelection?.length"
@click="atmStore.clearSelection()" @click="atmStore.clearSelection()"
@ -193,6 +213,7 @@ function formatFiat(cents: number): string {
Clear Clear
</Button> </Button>
<Button <Button
size="kiosk-lg"
class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500" class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
:disabled="!context?.cashOutSelection?.length" :disabled="!context?.cashOutSelection?.length"
@click="atmStore.confirmAmount()" @click="atmStore.confirmAmount()"
@ -204,68 +225,57 @@ function formatFiat(cents: number): string {
<!-- Instructions --> <!-- Instructions -->
<Alert class="border-bitcoin/50 bg-bitcoin/10"> <Alert class="border-bitcoin/50 bg-bitcoin/10">
<AlertDescription> <AlertDescription>
<p class="text-sm text-muted-foreground"> <p class="text-xl text-muted-foreground">
Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin. Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin.
</p> </p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</CardContent> </div>
</Card>
<!-- Generating Invoice --> <!-- Generating Invoice (centered) -->
<Card <div
v-else-if="nestedState === 'generatingInvoice'" v-else-if="nestedState === 'generatingInvoice'"
class="w-full max-w-80 border-0 bg-card" key="generatingInvoice"
class="flex flex-1 flex-col items-center justify-center gap-6"
> >
<CardContent class="flex flex-col items-center gap-4 p-8"> <BounceDots class="scale-150" />
<Skeleton class="h-10 w-10 rounded-full" /> <p class="text-2xl text-muted-foreground">Creating invoice...</p>
<p class="text-muted-foreground">Creating invoice...</p> </div>
</CardContent>
</Card>
<!-- Displaying Invoice - Show QR for payment --> <!-- Displaying Invoice (split-screen) -->
<Card <div
v-else-if="nestedState === 'displayingInvoice'" v-else-if="nestedState === 'displayingInvoice'"
class="w-full max-w-[480px] border-0 bg-card" key="displayingInvoice"
class="flex flex-1"
> >
<CardHeader class="text-center"> <!-- Left panel (52%) — info -->
<CardTitle>Scan to Pay</CardTitle> <div class="flex w-[52%] flex-col items-center justify-center gap-5 px-[4vw]">
<CardDescription v-if="context"> <p class="text-[2rem] font-semibold text-warning">Scan to Pay</p>
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to <p class="text-[7vh] font-bold text-bitcoin leading-tight">
receive {{ context ? formatSats(context.satsAmount) : 0 }} sats
<Badge class="bg-success" </p>
>{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</Badge <p class="text-2xl text-muted-foreground">
> to receive
</CardDescription> <Badge class="bg-success text-success-foreground text-2xl">
</CardHeader> {{ atmStore.fiatSymbol }}{{ context ? formatFiat(context.fiatAmount) : '0.00' }}
<CardContent class="space-y-6"> </Badge>
<!-- Invoice QR Code -->
<div class="flex flex-col items-center">
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet
</p> </p>
</div>
<!-- Payment status --> <!-- Waiting indicator -->
<div class="flex flex-col items-center gap-4 py-4"> <div class="flex items-center gap-3 pt-4">
<div class="h-12 w-12 animate-pulse rounded-full bg-bitcoin/20"> <BounceDots />
<div class="flex h-full items-center justify-center text-2xl">⏳</div> <p class="text-xl text-muted-foreground">Waiting for payment...</p>
</div>
<p class="text-center text-muted-foreground">Waiting for payment...</p>
</div> </div>
<!-- Invoice info with copy button (web-ui only) --> <!-- Invoice info with copy button (web-ui only) -->
<div v-if="context?.invoice && !isElectron" class="rounded-lg bg-card p-3"> <div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
<p class="mb-2 truncate text-center font-mono text-xs text-muted-foreground"> <p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">
{{ context.invoice.slice(0, 50) }}... {{ context.invoice.slice(0, 50) }}...
</p> </p>
<div class="flex justify-center">
<Button variant="outline" size="sm" @click="copyInvoice"> <Button variant="outline" size="sm" @click="copyInvoice">
{{ copyButtonText }} {{ copyButtonText }}
</Button> </Button>
</div> </div>
</div>
<!-- Dev controls --> <!-- Dev controls -->
<Alert v-if="atmStore.debugMode" class="border-destructive/50 bg-destructive/10"> <Alert v-if="atmStore.debugMode" class="border-destructive/50 bg-destructive/10">
@ -280,90 +290,105 @@ function formatFiat(cents: number): string {
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</CardContent> </div>
</Card>
<!-- Dispensing Cash --> <!-- Right panel (48%) — QR code -->
<Card v-else-if="nestedState === 'dispensingCash'" class="w-full max-w-80 border-0 bg-card"> <div class="flex w-[48%] flex-col items-center justify-center bg-foreground/5 p-6">
<CardContent class="flex flex-col items-center gap-4 p-8"> <div v-if="context?.invoice">
<Skeleton class="h-10 w-10 rounded-full" /> <QRCode :value="context.invoice" :size="380" />
<p class="text-muted-foreground">Dispensing cash...</p> </div>
<p class="text-sm text-muted-foreground">Please wait</p> <div v-else class="flex flex-col items-center gap-4">
</CardContent> <BounceDots class="scale-150" />
</Card> <p class="text-xl text-muted-foreground">Generating invoice...</p>
</div>
</div>
</div>
<!-- Cash Ready --> <!-- Dispensing Cash (centered) -->
<Card <div
v-else-if="nestedState === 'waitingForCashTaken'" v-else-if="nestedState === 'dispensingCash'"
class="w-full max-w-80 border-0 bg-success/20" key="dispensingCash"
class="flex flex-1 flex-col items-center justify-center gap-6"
> >
<CardContent class="flex flex-col items-center gap-4 p-8 text-center"> <BounceDots class="scale-150" />
<div class="text-6xl text-success">✅</div> <p class="text-2xl text-muted-foreground">Dispensing cash...</p>
<CardTitle class="text-success">Cash Ready!</CardTitle> <p class="text-xl text-muted-foreground">Please wait</p>
<p v-if="context"> </div>
<!-- Cash Ready (centered, success accent) -->
<div
v-else-if="nestedState === 'waitingForCashTaken'"
key="waitingForCashTaken"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
>
<div class="text-[8vh] text-success">✅</div>
<h3 class="text-[3rem] font-bold text-success">Cash Ready!</h3>
<p v-if="context" class="text-2xl">
Please take your Please take your
<Badge class="bg-success text-lg" <Badge class="bg-success text-success-foreground text-2xl"
>{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</Badge >{{ atmStore.fiatSymbol }}{{ 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"
size="lg" size="kiosk-lg"
@click="confirmCashTaken" @click="confirmCashTaken"
> >
I've Taken the Cash I've Taken the Cash
</Button> </Button>
</CardContent> </div>
</Card>
<!-- Ask for Receipt --> <!-- Ask for Receipt (centered) -->
<Card v-else-if="nestedState === 'askForReceipt'" class="w-full max-w-80 border-0 bg-card"> <div
<CardContent class="flex flex-col items-center gap-4 p-8"> v-else-if="nestedState === 'askForReceipt'"
<CardTitle>Would you like a receipt?</CardTitle> key="askForReceipt"
<p class="text-sm text-muted-foreground"> class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
Scan your npub to receive a Nostr DM receipt >
</p> <h3 class="text-[3rem] font-bold">Would you like a receipt?</h3>
<p class="text-2xl text-muted-foreground">Scan your npub to receive a Nostr DM receipt</p>
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-qr"> <div class="flex h-32 w-32 items-center justify-center rounded-lg bg-qr">
<p class="text-qr-foreground">📱 Scan npub</p> <p class="text-qr-foreground">📱 Scan npub</p>
</div> </div>
<Button variant="outline" @click="skipReceipt">No Thanks</Button> <Button variant="outline" size="kiosk" @click="skipReceipt">No Thanks</Button>
</CardContent> </div>
</Card>
<!-- Complete --> <!-- Complete (centered, success accent) -->
<Card v-else-if="nestedState === 'complete'" class="w-full max-w-80 border-0 bg-success/20"> <div
<CardContent class="flex flex-col items-center gap-4 p-8 text-center"> v-else-if="nestedState === 'complete'"
<div class="text-6xl">🎉</div> key="complete"
<CardTitle class="text-success">Transaction Complete!</CardTitle> class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
<p class="text-muted-foreground">Thank you!</p> style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
</CardContent>
</Card>
<!-- Error -->
<Card
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
class="w-full max-w-80 border-0 bg-destructive/20"
> >
<CardContent class="flex flex-col items-center gap-4 p-8 text-center"> <div class="text-[8vh]">🎉</div>
<div class="text-6xl">⚠️</div> <h3 class="text-[3rem] font-bold text-success">Transaction Complete!</h3>
<CardTitle class="text-destructive">Error</CardTitle> <p class="text-2xl text-muted-foreground">Thank you!</p>
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p> </div>
<!-- Error (centered, destructive accent) -->
<div
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
key="error"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
>
<div class="text-[8vh]">⚠️</div>
<h3 class="text-[3rem] font-bold text-destructive">Error</h3>
<p class="text-2xl text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
<Button <Button
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
size="kiosk"
@click="cancel" @click="cancel"
> >
Return to Start Return to Start
</Button> </Button>
</CardContent> </div>
</Card>
<!-- Fallback --> <!-- Fallback -->
<Card v-else class="w-full max-w-80 border-0 bg-card"> <div v-else key="fallback" class="flex flex-1 items-center justify-center">
<CardContent class="p-8 text-center"> <p class="text-xl text-muted-foreground">State: {{ nestedState }}</p>
<p class="text-muted-foreground">State: {{ nestedState }}</p> </div>
</CardContent> </Transition>
</Card>
</main> </main>
</ScrollArea>
</div> </div>
</template> </template>