fix(machine): UI polish — fiat rate display, 15s receipt with QR, remove CLINK

- Show exchange rate as fiat/BTC (e.g. Q615,000/BTC) instead of sats/fiat
- Show USD/BTC rate when currency != USD
- Complete screen stays 15s (was 3s) with txid QR code for receipt photo
- Add "Done" button for manual dismiss on complete screen
- Remove CLINK ndebit UI (mode selector, pubkey entry) pending k1 fix (#23)
- Remove askForReceipt/sendingReceipt screens (npub scan not active, #36)
- Remove negative sign on commission display
- Set timezone to America/Guatemala

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-01 15:36:18 -05:00
commit 254fbd26f2
4 changed files with 163 additions and 248 deletions

View file

@ -45,17 +45,12 @@ const showCancelButton = computed(() => {
return false
})
// CLINK Debit: customer's npub/pubkey
const pubkeyInput = ref('')
// Invoice input for manual payment
const invoiceInput = ref('')
// Copy state for ndebit URI
const copied = ref(false)
// 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)
@ -77,32 +72,9 @@ watch(
{ immediate: true }
)
// 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
// QR value — LNURL-withdraw only (CLINK ndebit disabled until k1 single-use fix)
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
@ -142,10 +114,6 @@ function finishInserting() {
atmStore.finishInserting()
}
function skipReceipt() {
atmStore.skipReceipt()
}
// Check if a bill denomination can still be accepted without exceeding available balance
function canAcceptBill(denomination: number): boolean {
const ctx = context.value
@ -168,29 +136,6 @@ function simulateBill(denomination: number) {
atmStore.insertBill(denomination)
}
// Convert npub to hex pubkey if needed
function normalizePubkey(input: string): string {
const trimmed = input.trim()
if (trimmed.startsWith('npub1')) {
console.log('[CashIn] npub detected, would decode in production')
}
return trimmed
}
// Request CLINK Debit (Nostr-native payment)
async function submitDebitRequest() {
const pubkey = normalizePubkey(pubkeyInput.value)
if (!pubkey) return
// Basic validation - should be 64 hex chars or npub
if (!pubkey.startsWith('npub1') && !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
alert('Please enter a valid Nostr pubkey (64 hex chars) or npub')
return
}
await atmStore.requestDebit(pubkey)
}
// Pay invoice (manual BOLT-11 payment)
async function submitInvoice() {
const invoice = invoiceInput.value.trim()
@ -209,7 +154,14 @@ function formatSats(sats: number): string {
return sats.toLocaleString()
}
const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isRequestingDebit)
/** Convert sats-per-fiat-unit exchange rate to fiat-per-BTC display string */
function formatFiatPrice(rateOrPrice: number): string {
// If it looks like a sats/fiat rate, convert to fiat/BTC
const price = rateOrPrice > 10000 ? rateOrPrice : Math.round(100_000_000 / rateOrPrice)
return price.toLocaleString()
}
const isProcessing = computed(() => atmStore.isPayingInvoice)
</script>
<template>
@ -265,7 +217,13 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<!-- Rate -->
<p v-if="context" class="text-xl text-muted-foreground">
Rate: {{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}
{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC
</p>
<p
v-if="context && atmStore.fiatCode !== 'USD' && atmStore.btcPriceUsd"
class="text-lg text-muted-foreground"
>
${{ formatFiatPrice(atmStore.btcPriceUsd) }} USD/BTC
</p>
<!-- Available balance -->
@ -355,51 +313,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<p class="text-xl text-muted-foreground">Waiting for wallet scan...</p>
</div>
<!-- Mode selector (web-ui only) -->
<div v-if="!isElectron" class="pt-4 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>
<!-- Mode label + truncated URI (web-ui only) -->
<div v-if="!isElectron" class="text-center space-y-1">
<!-- LNURL URI (web-ui only) -->
<div v-if="!isElectron && currentQrValue" class="pt-4 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'
}}
LNURL-withdraw
</p>
<div v-if="currentQrValue" class="flex items-center justify-center gap-2">
<div class="flex items-center justify-center gap-2">
<code class="font-mono-code text-xs text-muted-foreground truncate max-w-[280px]">
{{ currentQrValue.split('\n')[0]?.slice(0, 32) }}...
{{ currentQrValue.slice(0, 32) }}...
</code>
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
<span v-if="copied">✓</span>
@ -467,38 +388,15 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</Button>
</div>
<!-- Ask for Receipt (centered) -->
<div
v-else-if="nestedState === 'askForReceipt'"
key="askForReceipt"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
>
<div class="text-[8vh] text-success">✅</div>
<h3 class="text-[3rem] font-bold">Sats Sent!</h3>
<p class="text-2xl text-muted-foreground">Would you like a receipt?</p>
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-qr">
<p class="text-qr-foreground">📱 Scan npub</p>
</div>
<Button variant="outline" size="kiosk" @click="skipReceipt">No Thanks</Button>
</div>
<!-- Sending Receipt (centered) -->
<div
v-else-if="nestedState === 'sendingReceipt'"
key="sendingReceipt"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<BounceDots class="scale-150" />
<p class="text-2xl text-muted-foreground">Sending receipt...</p>
</div>
<!-- Complete (centered, success accent) -->
<div
v-else-if="nestedState === 'complete'"
key="complete"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
class="flex flex-1 items-center justify-center gap-10 p-8"
style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
>
<!-- Left side — details -->
<div class="flex flex-col items-center gap-5">
<div class="text-[8vh]">🎉</div>
<h3 class="text-[3rem] font-bold text-success">Transaction Complete!</h3>
@ -515,14 +413,16 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</div>
<div class="flex justify-between text-2xl">
<span class="text-muted-foreground">Rate</span>
<span>{{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}</span>
<span
>{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC</span
>
</div>
<div class="flex justify-between text-2xl">
<span class="text-muted-foreground"
>Commission ({{ (context.feePercent * 100).toFixed(2) }}%)</span
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
>
<span class="text-destructive"
>-{{
<span
>{{
formatSats(
Math.floor(
Math.floor((context.fiatAmount / 100) * context.exchangeRate) *
@ -540,7 +440,22 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</div>
</div>
<p class="text-2xl text-muted-foreground">Thank you!</p>
<p class="text-xl text-muted-foreground">
Take a photo of this screen for your receipt
</p>
<Button variant="outline" size="kiosk" @click="cancel"> Done </Button>
</div>
<!-- Right side — txid QR -->
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
<QRCode :value="context.txid" :size="280" />
<p
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
>
{{ context.txid }}
</p>
</div>
</div>
<!-- Error (centered, destructive accent) -->
@ -586,30 +501,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
Alternative: Manual withdraw options
</summary>
<div class="mt-4 space-y-4">
<!-- CLINK Debit by pubkey -->
<div class="space-y-2">
<p class="text-xs font-medium text-accent">Enter your npub/pubkey:</p>
<div class="flex gap-2">
<Input
v-model="pubkeyInput"
placeholder="npub1... or hex pubkey"
class="flex-1 font-mono-code text-xs"
:disabled="isProcessing"
/>
<Button
variant="outline"
size="sm"
:disabled="!pubkeyInput.trim() || isProcessing"
@click="submitDebitRequest"
>
{{ atmStore.isRequestingDebit ? '...' : 'Send' }}
</Button>
</div>
</div>
<!-- Invoice paste -->
<div class="space-y-2">
<p class="text-xs font-medium text-bitcoin">Or paste your invoice:</p>
<p class="text-xs font-medium text-bitcoin">Paste your invoice:</p>
<div class="flex gap-2">
<Input
v-model="invoiceInput"

View file

@ -98,14 +98,16 @@ function confirmCashTaken() {
atmStore.cashTaken()
}
function skipReceipt() {
atmStore.skipReceipt()
}
function formatSats(sats: number): string {
return sats.toLocaleString()
}
/** Convert sats-per-fiat-unit exchange rate to fiat-per-BTC display string */
function formatFiatPrice(rateOrPrice: number): string {
const price = rateOrPrice > 10000 ? rateOrPrice : Math.round(100_000_000 / rateOrPrice)
return price.toLocaleString()
}
function formatFiat(cents: number): string {
return (cents / 100).toFixed(2)
}
@ -196,9 +198,15 @@ function formatFiat(cents: number): string {
<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 }}
{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC
</Badge>
</p>
<p
v-if="context && atmStore.fiatCode !== 'USD' && atmStore.btcPriceUsd"
class="mt-1 text-base text-muted-foreground"
>
${{ formatFiatPrice(atmStore.btcPriceUsd) }} USD/BTC
</p>
</div>
<!-- Action buttons -->
@ -339,27 +347,15 @@ function formatFiat(cents: number): string {
</Button>
</div>
<!-- Ask for Receipt (centered) -->
<div
v-else-if="nestedState === 'askForReceipt'"
key="askForReceipt"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
>
<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">
<p class="text-qr-foreground">📱 Scan npub</p>
</div>
<Button variant="outline" size="kiosk" @click="skipReceipt">No Thanks</Button>
</div>
<!-- Complete (centered, success accent) -->
<div
v-else-if="nestedState === 'complete'"
key="complete"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
class="flex flex-1 items-center justify-center gap-10 p-8"
style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
>
<!-- Left side — details -->
<div class="flex flex-col items-center gap-5">
<div class="text-[8vh]">🎉</div>
<h3 class="text-[3rem] font-bold text-success">Transaction Complete!</h3>
@ -374,18 +370,22 @@ function formatFiat(cents: number): string {
</div>
<div class="flex justify-between text-2xl">
<span class="text-muted-foreground">Rate</span>
<span>{{ formatSats(context.exchangeRate) }} sats/{{ atmStore.fiatSymbol }}</span>
<span
>{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC</span
>
</div>
<div class="flex justify-between text-2xl">
<span class="text-muted-foreground"
>Commission ({{ (context.feePercent * 100).toFixed(2) }}%)</span
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
>
<span class="text-destructive"
>-{{
<span
>{{
formatSats(
Math.abs(
context.satsAmount -
Math.floor((context.fiatAmount / 100) * context.exchangeRate)
)
)
}}
sats</span
>
@ -399,7 +399,22 @@ function formatFiat(cents: number): string {
</div>
</div>
<p class="text-2xl text-muted-foreground">Thank you!</p>
<p class="text-xl text-muted-foreground">
Take a photo of this screen for your receipt
</p>
<Button variant="outline" size="kiosk" @click="cancel"> Done </Button>
</div>
<!-- Right side — txid QR -->
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
<QRCode :value="context.txid" :size="280" />
<p
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
>
{{ context.txid }}
</p>
</div>
</div>
<!-- Error (centered, destructive accent) -->

View file

@ -23,7 +23,7 @@
};
# Timezone - set to your location
time.timeZone = "UTC";
time.timeZone = "America/Guatemala";
# Locale
i18n.defaultLocale = "en_US.UTF-8";

View file

@ -379,7 +379,7 @@ export function createATMMachine(
},
delays: {
TIMEOUT_MS: 300000, // 5 minutes
COMPLETE_DELAY: 3000,
COMPLETE_DELAY: 15000,
},
}).createMachine({
id: 'atm',
@ -482,7 +482,7 @@ export function createATMMachine(
},
on: {
PAYMENT_RECEIVED: {
target: 'askForReceipt',
target: 'complete',
actions: 'setPaymentReceived',
},
PAYMENT_FAILED: {
@ -523,6 +523,9 @@ export function createATMMachine(
after: {
COMPLETE_DELAY: '#atm.idle',
},
on: {
CANCEL: '#atm.idle',
},
},
error: {
on: {
@ -653,8 +656,8 @@ export function createATMMachine(
},
waitingForCashTaken: {
on: {
CASH_DISPENSED: 'askForReceipt',
TIMEOUT: 'askForReceipt', // Assume taken
CASH_DISPENSED: 'complete',
TIMEOUT: 'complete', // Assume taken
},
},
askForReceipt: {
@ -680,6 +683,9 @@ export function createATMMachine(
after: {
COMPLETE_DELAY: '#atm.idle',
},
on: {
CANCEL: '#atm.idle',
},
},
dispenseError: {
// Critical error - payment received but cash not dispensed