feat(machine): show the card holder's balance, hidden by default

A tap-to-enter session is effectively the holder logging into their card
wallet, so show its balance — but a kiosk in a public place must not
display a stranger's balance unasked. CardChip renders the card label
with the balance masked (••••••) and an eye toggle; revealed, it mirrors
the LNbits wallet page: sats, then the fiat equivalent formatted with
Intl currency style. Fiat comes from the card server (the wallet's own
currency, else the instance default, at its rate) and falls back to the
ATM's fiat at its display rate when the server priced nothing. Shown on
the idle menu and both cash screens; reveal state resets on re-lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 17:07:17 +02:00
commit 6779d8ef55
5 changed files with 112 additions and 6 deletions

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
* kiosk in a public space must not show a stranger's balance unasked. The
* revealed line mirrors the LNbits wallet page: sats, then the fiat
* equivalent in the wallet's own currency (Intl currency formatting), falling
* back to the ATM's fiat at its display rate when the card server priced
* nothing. Reveal state lives in the store and resets on re-lock.
*/
import { computed } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { Button } from '@/components/ui/button'
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
const atmStore = useAtmStore()
const card = computed(() => atmStore.loadedBoltCard)
const label = computed(() => {
const c = card.value
if (!c) return ''
return c.cardName
? `${c.cardName} · ••${c.externalId.slice(-4)}`
: `Card ••${c.externalId.slice(-4)}`
})
const sats = computed(() =>
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
)
const fiat = computed(() => {
const f = atmStore.loadedCardFiat
if (!f) return null
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
f.amount
)
} catch {
return `${f.amount.toFixed(2)} ${f.currency}`
}
})
</script>
<template>
<div
v-if="card"
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
>
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
<div class="flex min-w-0 flex-col leading-tight">
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
{{ label }}
</span>
<span
v-if="atmStore.cardBalanceRevealed"
class="text-base font-semibold text-foreground lg:text-2xl"
>
{{ sats }} sats
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
</span>
<span
v-else
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
aria-label="Balance hidden"
>
••••••
</span>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
@click="atmStore.toggleCardBalance()"
>
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
<Eye v-else class="size-5 lg:size-7" />
</Button>
</div>
</template>

View file

@ -324,6 +324,9 @@ export const useAtmStore = defineStore('atm', () => {
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when // hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
// the machine re-locks. Never logged. // the machine re-locks. Never logged.
const loadedBoltCard = ref<CardSession | null>(null) const loadedBoltCard = ref<CardSession | null>(null)
// The card balance is hidden by default on the public screen; the holder
// reveals it with the eye toggle. Resets on re-lock.
const cardBalanceRevealed = ref(false)
const fiatCode = ref('USD') const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr // Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin) // kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
@ -459,6 +462,25 @@ export const useAtmStore = defineStore('atm', () => {
// start), so App.vue's LockedView branch never renders on a non-access machine. // start), so App.vue's LockedView branch never renders on a non-access machine.
const isLocked = computed(() => currentState.value === 'locked') const isLocked = computed(() => currentState.value === 'locked')
/**
* Fiat view of the loaded card's balance, the way the LNbits wallet page
* prices it: the card server's own currency and rate first (the wallet's
* currency, else the instance default); when it priced nothing, the ATM's
* fiat at its display rate. Null when neither is available.
*/
const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => {
const card = loadedBoltCard.value
if (!card) return null
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
if (btcPrice.value && btcPrice.value > 0) {
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
}
return null
})
function toggleCardBalance() {
cardBalanceRevealed.value = !cardBalanceRevealed.value
}
const isCashIn = computed(() => { const isCashIn = computed(() => {
const state = snapshot.value?.value const state = snapshot.value?.value
return typeof state === 'object' && 'cashIn' in state return typeof state === 'object' && 'cashIn' in state
@ -517,6 +539,7 @@ export const useAtmStore = defineStore('atm', () => {
// re-locks) so the next customer starts fresh — never carry a card over. // re-locks) so the next customer starts fresh — never carry a card over.
if (state === 'locked' && loadedBoltCard.value) { if (state === 'locked' && loadedBoltCard.value) {
loadedBoltCard.value = null loadedBoltCard.value = null
cardBalanceRevealed.value = false
} }
// Detect network from first invoice we see // Detect network from first invoice we see
@ -753,6 +776,7 @@ export const useAtmStore = defineStore('atm', () => {
}) })
if (outcome.status === 'granted') { if (outcome.status === 'granted') {
loadedBoltCard.value = opened.session loadedBoltCard.value = opened.session
cardBalanceRevealed.value = false
nfcStatus.value = { state: 'accepted', message: 'Card accepted' } nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
grantAccess(outcome.role, outcome.credentialIdHash) grantAccess(outcome.role, outcome.credentialIdHash)
} else { } else {
@ -1869,6 +1893,9 @@ export const useAtmStore = defineStore('atm', () => {
simulateBoltCardReceive, simulateBoltCardReceive,
// Tap-to-enter: card session opened at the locked screen, reused at Complete // Tap-to-enter: card session opened at the locked screen, reused at Complete
loadedBoltCard, loadedBoltCard,
cardBalanceRevealed,
loadedCardFiat,
toggleCardBalance,
completeWithCard, completeWithCard,
simulateBoltCardEntry, simulateBoltCardEntry,

View file

@ -3,6 +3,7 @@ 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 CardChip from '@/components/CardChip.vue'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -368,9 +369,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
v-if="atmStore.loadedBoltCard" v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2" class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
> >
<p class="text-xs uppercase tracking-wide text-muted-foreground"> <CardChip class="w-full" />
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
</p>
<Button <Button
class="w-full bg-success text-success-foreground" class="w-full bg-success text-success-foreground"
size="kiosk-lg" size="kiosk-lg"

View file

@ -3,6 +3,7 @@ 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 CardChip from '@/components/CardChip.vue'
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 QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -333,9 +334,7 @@ function formatFiat(cents: number): string {
v-if="atmStore.loadedBoltCard" v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2" class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
> >
<p class="text-xs uppercase tracking-wide text-muted-foreground"> <CardChip class="w-full" />
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
</p>
<Button <Button
class="w-full bg-success text-success-foreground" class="w-full bg-success text-success-foreground"
size="kiosk-lg" size="kiosk-lg"

View file

@ -5,6 +5,7 @@ import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding' import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine' import { initialContext } from '@bitSpire/state-machine'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CardChip from '@/components/CardChip.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import BitcoinIcon from '@/components/BitcoinIcon.vue' import BitcoinIcon from '@/components/BitcoinIcon.vue'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
@ -104,6 +105,8 @@ function handleCashOut() {
> >
</span> </span>
</div> </div>
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
</div> </div>
<!-- Touch Zones --> <!-- Touch Zones -->