feat(machine): wire Bolt Card tap into cash-out displayingInvoice + UI
Renderer side of tap-to-pay. The store subscribes to the main-process reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls payment for the shown invoice via lnurlWithdraw (amount in msats), guarded against double-taps. Settlement still flows through the existing invoice watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is unchanged. Bolt Card state clears when leaving the invoice screen. CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined status on the invoice screen, plus a dev input to simulate a tap with a pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
84d746a0da
commit
07978a8de1
2 changed files with 116 additions and 5 deletions
|
|
@ -291,6 +291,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const debugMode = ref(true)
|
||||
const allowMockFallback = ref(true) // default true for browser dev
|
||||
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
||||
// Bolt Card cash-out (NFC tap-to-pay). nfcStatus surfaces reader state on the
|
||||
// invoice screen; boltCardProcessing gates against double-taps while a pull
|
||||
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||
const boltCardProcessing = ref(false)
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -562,14 +567,73 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Clear Bolt Card state whenever we leave the invoice screen (dispensed,
|
||||
// timed out, or cancelled) so a stale "processing"/error can't linger.
|
||||
if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = null
|
||||
}
|
||||
|
||||
prevNestedState = currentNested
|
||||
})
|
||||
|
||||
// Start the machine
|
||||
actor.value.start()
|
||||
setupNfcListener()
|
||||
console.log('[ATM] State machine initialized')
|
||||
}
|
||||
|
||||
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tapped Bolt Card during the cash-out invoice screen: pull payment for
|
||||
* the shown invoice via LNURL-withdraw (main process). Settlement still
|
||||
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||
* ok here only means the card accepted the pull.
|
||||
*/
|
||||
async function handleBoltCardTap(lnurlw: string) {
|
||||
if (nestedState.value !== 'displayingInvoice') return
|
||||
const invoice = context.value?.invoice
|
||||
if (!invoice) return
|
||||
if (boltCardProcessing.value) return // one pull at a time
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
||||
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
||||
if (res.ok) {
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||
} else {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||
function setupNfcListener() {
|
||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
})
|
||||
window.electronAPI.onNfcStatus?.((status) => {
|
||||
// Only surface reader status on the invoice screen, and don't clobber an
|
||||
// in-flight pull's message.
|
||||
if (nestedState.value === 'displayingInvoice' && !boltCardProcessing.value) {
|
||||
nfcStatus.value = status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */
|
||||
function simulateBoltCardTap(lnurlw: string) {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||
*/
|
||||
|
|
@ -1522,6 +1586,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
isCashOut,
|
||||
nestedState,
|
||||
|
||||
// Bolt Card cash-out (NFC)
|
||||
nfcStatus,
|
||||
boltCardProcessing,
|
||||
simulateBoltCardTap,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
initializeWithLightning,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
|
||||
const cashOutSteps = ['Select', 'Pay', 'Collect']
|
||||
|
||||
// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without
|
||||
// the reader (single-use, so a live tap each time).
|
||||
const mockLnurlw = ref('')
|
||||
|
||||
const currentStepIndex = computed(() => {
|
||||
switch (nestedState.value) {
|
||||
case 'fetchingRate':
|
||||
|
|
@ -284,7 +288,9 @@ function formatFiat(cents: number): string {
|
|||
<div
|
||||
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
|
||||
>
|
||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p>
|
||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">
|
||||
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
|
||||
</p>
|
||||
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||
</p>
|
||||
|
|
@ -295,10 +301,31 @@ function formatFiat(cents: number): string {
|
|||
</Badge>
|
||||
</p>
|
||||
|
||||
<!-- Waiting indicator -->
|
||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||
<!-- Waiting indicator + Bolt Card status -->
|
||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<PickaxeIcon :size="32" />
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p>
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||
{{
|
||||
atmStore.boltCardProcessing
|
||||
? 'Processing card…'
|
||||
: isElectron
|
||||
? 'Tap your card or scan the QR'
|
||||
: 'Waiting for payment...'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="atmStore.nfcStatus?.message"
|
||||
class="text-sm lg:text-lg"
|
||||
:class="
|
||||
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ atmStore.nfcStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info with copy button (web-ui only) -->
|
||||
|
|
@ -322,6 +349,21 @@ function formatFiat(cents: number): string {
|
|||
>
|
||||
Simulate Payment
|
||||
</Button>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a card tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardTap(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue