fix(machine): add mobile responsive layout for atm.aiolabs.dev

The kiosk UI was optimized for 1920px touchscreens, breaking mobile web.
Uses Tailwind lg: breakpoints (mobile-first) so mobile works naturally
while kiosk sizing applies at 1024px+. No JS branching — pure CSS.

- Viewport: width=1920 → width=device-width
- Kiosk-only: overflow:hidden and cursor:none behind @media (min-width: 1024px)
- IdleView: stack buttons vertically on mobile, justify-around for even spacing
- CashInView/CashOutView: stack split panels on mobile (flex-col-reverse lg:flex-row)
- QRCode: SVG scales down on small screens via max-w-full
- App.vue: hide verbose badges on mobile, debug bar collapses theme/color selectors
- Connection/network badges always visible (not gated by isIdle)
- Reduce zone-glow animation intensity by half (20px/4px vs 40px/8px)
- All lg: values verified to match original kiosk rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-01 23:41:18 -05:00
commit 7a42380f0e
7 changed files with 257 additions and 149 deletions

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=1920, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Lamassu ATM</title> <title>Lamassu ATM</title>
<style> <style>
/* Prevent text selection and context menu on kiosk */ /* Prevent text selection and context menu on kiosk */
@ -17,10 +17,16 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden;
background: #000; background: #000;
}
/* Kiosk-only: lock overflow and hide cursor */
@media (min-width: 1024px) {
html,
body {
overflow: hidden;
cursor: none; cursor: none;
} }
}
</style> </style>
</head> </head>
<body> <body>

View file

@ -55,7 +55,9 @@ function toggleLiveServices() {
</script> </script>
<template> <template>
<div class="flex h-dvh w-screen flex-col overflow-hidden bg-background font-sans text-foreground"> <div
class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground"
>
<!-- Maintenance screen: shown when initialization fails in production --> <!-- Maintenance screen: shown when initialization fails in production -->
<div <div
v-if="atmStore.initError" v-if="atmStore.initError"
@ -77,8 +79,8 @@ function toggleLiveServices() {
<line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
<h1 class="text-[3.5rem] font-bold">ATM Unavailable</h1> <h1 class="text-2xl lg:text-[3.5rem] font-bold">ATM Unavailable</h1>
<p class="max-w-md text-center text-2xl text-muted-foreground"> <p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
This machine is temporarily out of service. Please try again later or use another machine. This machine is temporarily out of service. Please try again later or use another machine.
</p> </p>
<p v-if="atmStore.debugMode" class="max-w-lg text-center font-mono text-sm text-destructive"> <p v-if="atmStore.debugMode" class="max-w-lg text-center font-mono text-sm text-destructive">
@ -89,42 +91,47 @@ function toggleLiveServices() {
<template v-else> <template v-else>
<router-view /> <router-view />
<!-- Connection status + balance (top right, idle only) --> <!-- Connection status + network (always visible, top right) -->
<div <div
v-if="atmStore.isIdle" class="fixed right-2 top-2 lg:right-6 lg:top-6 z-50 flex flex-wrap items-center justify-end gap-1 lg:gap-2"
class="fixed right-6 top-6 z-50 flex flex-wrap items-center justify-end gap-2"
> >
<!-- Price and balance: only on idle, hidden on mobile -->
<Badge <Badge
v-if="formattedBtcPrice" v-if="atmStore.isIdle && formattedBtcPrice"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-base" class="hidden lg:inline-flex bg-primary/10 font-mono text-primary border border-primary/30 text-base"
> >
{{ formattedBtcPrice }} {{ formattedBtcPrice }}
</Badge> </Badge>
<Badge <Badge
v-if="atmStore.balanceSats !== null" v-if="atmStore.isIdle && atmStore.balanceSats !== null"
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-base" class="hidden lg:inline-flex bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-base"
> >
bal: {{ formatSats(atmStore.balanceSats) }} sats bal: {{ formatSats(atmStore.balanceSats) }} sats
</Badge> </Badge>
<!-- Connection status: always visible -->
<Badge <Badge
v-if="atmStore.connectionStatus === 'connected'" v-if="atmStore.connectionStatus === 'connected'"
class="bg-success text-success-foreground" class="bg-success text-success-foreground text-[10px] lg:text-xs"
> >
Live Live
</Badge> </Badge>
<Badge <Badge
v-else-if="atmStore.connectionStatus === 'connecting'" v-else-if="atmStore.connectionStatus === 'connecting'"
class="bg-warning text-warning-foreground" class="bg-warning text-warning-foreground text-[10px] lg:text-xs"
> >
Connecting... Connecting...
</Badge> </Badge>
<Badge v-else-if="atmStore.connectionStatus === 'error'" class="bg-destructive"> <Badge
v-else-if="atmStore.connectionStatus === 'error'"
class="bg-destructive text-[10px] lg:text-xs"
>
Offline Offline
</Badge> </Badge>
<Badge v-else class="bg-muted text-muted-foreground">Mock</Badge> <Badge v-else class="bg-muted text-muted-foreground text-[10px] lg:text-xs">Mock</Badge>
<!-- Network badge: always visible when not mainnet -->
<Badge <Badge
v-if="atmStore.network && atmStore.network !== 'mainnet'" v-if="atmStore.network && atmStore.network !== 'mainnet'"
class="bg-warning/20 text-warning border border-warning/30" class="bg-warning/20 text-warning border border-warning/30 text-[10px] lg:text-xs"
> >
{{ atmStore.network }} {{ atmStore.network }}
</Badge> </Badge>
@ -134,12 +141,11 @@ function toggleLiveServices() {
<Button <Button
v-if="!atmStore.allowMockFallback" v-if="!atmStore.allowMockFallback"
variant="outline" variant="outline"
size="kiosk" class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
class="fixed bottom-6 right-6 z-50 gap-3"
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'" @click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
> >
<Sun v-if="colorMode === 'dark'" :size="28" /> <Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
<Moon v-else :size="28" /> <Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }} {{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button> </Button>
@ -148,7 +154,8 @@ function toggleLiveServices() {
v-if="atmStore.debugMode" v-if="atmStore.debugMode"
class="shrink-0 bg-popover pb-[env(safe-area-inset-bottom)] font-mono text-xs text-popover-foreground" class="shrink-0 bg-popover pb-[env(safe-area-inset-bottom)] font-mono text-xs text-popover-foreground"
> >
<div class="flex flex-wrap items-center gap-2 px-4 py-2 sm:gap-4"> <!-- Compact bar: always visible -->
<div class="flex flex-wrap items-center gap-2 lg:gap-4 px-3 py-1.5 lg:px-4 lg:py-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -157,11 +164,17 @@ function toggleLiveServices() {
> >
{{ debugExpanded ? '▼' : '▲' }} {{ debugExpanded ? '▼' : '▲' }}
</Button> </Button>
<span class="text-success">State: {{ atmStore.currentState }}</span> <span class="text-success text-[10px] lg:text-xs">{{ atmStore.currentState }}</span>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices"> <Button
{{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }} variant="outline"
size="sm"
class="h-5 lg:h-6 text-[10px] lg:text-xs"
@click="toggleLiveServices"
>
{{ atmStore.useLiveServices ? 'Mock' : 'Live' }}
</Button> </Button>
<div class="flex flex-wrap items-center gap-1"> <!-- Theme + color mode: hidden on mobile, shown inline on desktop -->
<div class="hidden lg:flex flex-wrap items-center gap-1">
<Button <Button
v-for="theme in themes" v-for="theme in themes"
:key="theme.id" :key="theme.id"
@ -173,7 +186,7 @@ function toggleLiveServices() {
{{ theme.label }} {{ theme.label }}
</Button> </Button>
</div> </div>
<div class="flex items-center gap-1"> <div class="hidden lg:flex items-center gap-1">
<Button <Button
:variant="colorMode === 'light' ? 'default' : 'outline'" :variant="colorMode === 'light' ? 'default' : 'outline'"
size="sm" size="sm"
@ -202,14 +215,60 @@ function toggleLiveServices() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="ml-auto h-6 text-xs text-muted-foreground" class="ml-auto h-5 lg:h-6 text-[10px] lg:text-xs text-muted-foreground"
@click="atmStore.toggleDebug" @click="atmStore.toggleDebug"
> >
Hide Debug Hide
</Button> </Button>
</div> </div>
<div v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4"> <!-- Expanded section: theme/color on mobile + JSON context -->
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre> <div v-if="debugExpanded" class="px-3 pb-3 lg:px-4 lg:pb-4 space-y-2">
<!-- Theme + color mode on mobile (hidden on desktop since shown above) -->
<div class="lg:hidden space-y-2">
<div class="flex flex-wrap items-center gap-1">
<Button
v-for="theme in themes"
:key="'m-' + theme.id"
:variant="currentTheme === theme.id ? 'default' : 'outline'"
size="sm"
class="h-5 text-[10px]"
@click="currentTheme = theme.id"
>
{{ theme.label }}
</Button>
</div>
<div class="flex items-center gap-1">
<Button
:variant="colorMode === 'light' ? 'default' : 'outline'"
size="sm"
class="h-5 text-[10px]"
@click="colorMode = 'light'"
>
Light
</Button>
<Button
:variant="colorMode === 'dark' ? 'default' : 'outline'"
size="sm"
class="h-5 text-[10px]"
@click="colorMode = 'dark'"
>
Dark
</Button>
<Button
:variant="colorMode === 'system' ? 'default' : 'outline'"
size="sm"
class="h-5 text-[10px]"
@click="colorMode = 'system'"
>
System
</Button>
</div>
</div>
<div class="max-h-48 overflow-auto">
<pre class="text-muted-foreground text-[10px] lg:text-xs">{{
JSON.stringify(atmStore.context, null, 2)
}}</pre>
</div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -62,7 +62,7 @@ onUnmounted(() => {
/> />
<!-- QR code --> <!-- QR code -->
<div class="rounded-xl bg-qr p-6"> <div class="rounded-xl bg-qr p-4 lg:p-6 max-w-full [&_svg]:max-w-full [&_svg]:h-auto">
<QrcodeVue <QrcodeVue
:value="value" :value="value"
:size="qrSize" :size="qrSize"

View file

@ -624,7 +624,7 @@
box-shadow: 0 0 0 0 var(--glow-color, transparent); box-shadow: 0 0 0 0 var(--glow-color, transparent);
} }
50% { 50% {
box-shadow: 0 0 40px 8px var(--glow-color, transparent); box-shadow: 0 0 20px 4px var(--glow-color, transparent);
} }
} }
@utility animate-zone-glow { @utility animate-zone-glow {

View file

@ -168,24 +168,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<!-- Header with success flow accent --> <!-- Header with success flow accent -->
<header <header
class="flex items-center px-8 py-4" class="flex items-center px-4 lg:px-8 py-2 lg:py-4"
style="background: color-mix(in srgb, var(--success) 10%, var(--background))" style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
> >
<Button <Button
v-if="showCancelButton" v-if="showCancelButton"
variant="ghost" variant="ghost"
size="kiosk" size="kiosk"
class="text-muted-foreground" class="text-muted-foreground text-sm h-auto min-h-0 px-2 py-1 lg:text-2xl lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3"
@click="cancel" @click="cancel"
> >
← Cancel ← Cancel
</Button> </Button>
<div v-else class="w-20"></div> <div v-else class="w-16 lg:w-20"></div>
<div class="flex flex-1 flex-col items-center gap-2"> <div class="flex flex-1 flex-col items-center gap-1 lg:gap-2">
<h2 class="text-[2.5rem] font-semibold">Buy Bitcoin</h2> <h2 class="text-xl lg:text-[2.5rem] font-semibold">Buy Bitcoin</h2>
<StepIndicator :steps="cashInSteps" :current-step="currentStepIndex" /> <StepIndicator :steps="cashInSteps" :current-step="currentStepIndex" />
</div> </div>
<div class="w-20"></div> <div class="w-16 lg:w-20"></div>
</header> </header>
<!-- Content area --> <!-- Content area -->
@ -202,26 +202,32 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</div> </div>
<!-- Inserting Bills (split-screen) --> <!-- Inserting Bills (split-screen) -->
<div v-else-if="nestedState === 'insertingBills'" key="insertingBills" class="flex flex-1"> <div
<!-- Left panel (52%) — amounts and controls --> v-else-if="nestedState === 'insertingBills'"
<div class="flex w-[52%] flex-col justify-center gap-6 px-[4vw] py-6"> key="insertingBills"
class="flex flex-1 flex-col lg:flex-row"
>
<!-- Left panel — amounts and controls -->
<div
class="flex w-full lg:w-[52%] flex-col justify-center gap-4 lg:gap-6 px-4 lg:px-[4vw] py-4 lg:py-6"
>
<!-- Amount display --> <!-- Amount display -->
<div v-if="context"> <div v-if="context">
<p class="text-[6vh] font-bold text-success leading-tight"> <p class="text-3xl lg:text-[6vh] font-bold text-success leading-tight">
{{ atmStore.fiatSymbol }}{{ (context.fiatAmount / 100).toFixed(2) }} {{ atmStore.fiatSymbol }}{{ (context.fiatAmount / 100).toFixed(2) }}
</p> </p>
<p class="text-[3.5vh] font-semibold text-bitcoin"> <p class="text-xl lg:text-[3.5vh] font-semibold text-bitcoin">
{{ formatSats(context.satsAmount) }} sats {{ formatSats(context.satsAmount) }} sats
</p> </p>
</div> </div>
<!-- Rate --> <!-- Rate -->
<p v-if="context" class="text-xl text-muted-foreground"> <p v-if="context" class="text-sm lg:text-xl text-muted-foreground">
{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC {{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC
</p> </p>
<p <p
v-if="context && atmStore.fiatCode !== 'USD' && atmStore.btcPriceUsd" v-if="context && atmStore.fiatCode !== 'USD' && atmStore.btcPriceUsd"
class="text-lg text-muted-foreground" class="text-sm lg:text-lg text-muted-foreground"
> >
${{ formatFiatPrice(atmStore.btcPriceUsd) }} USD/BTC ${{ formatFiatPrice(atmStore.btcPriceUsd) }} USD/BTC
</p> </p>
@ -229,17 +235,19 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
<!-- Available balance --> <!-- Available balance -->
<p <p
v-if="context && context.availableBalance > 0" v-if="context && context.availableBalance > 0"
class="text-xl" class="text-sm lg:text-xl"
:class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'" :class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'"
> >
Available: {{ formatSats(context.availableBalance) }} sats Available: {{ formatSats(context.availableBalance) }} sats
</p> </p>
<!-- Status --> <!-- Status -->
<p v-if="balanceLimitReached" class="text-xl text-muted-foreground"> <p v-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
Maximum amount reached — press Done to continue Maximum amount reached — press Done to continue
</p> </p>
<p v-else class="text-xl text-muted-foreground">💵 Insert bills into the acceptor</p> <p v-else class="text-sm lg:text-xl text-muted-foreground">
💵 Insert bills into the acceptor
</p>
<!-- Dev: Simulate bill insertion --> <!-- Dev: Simulate bill insertion -->
<Alert v-if="atmStore.debugMode" class="border-destructive/50 bg-destructive/10"> <Alert v-if="atmStore.debugMode" class="border-destructive/50 bg-destructive/10">
@ -272,8 +280,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</Button> </Button>
</div> </div>
<!-- Right panel (48%) — bill visual --> <!-- Right panel — bill visual (kiosk only) -->
<div class="flex w-[48%] flex-col items-center justify-center gap-6 bg-foreground/5"> <div
class="hidden lg:flex w-[48%] flex-col items-center justify-center gap-6 bg-foreground/5"
>
<span class="text-[14vh] leading-none">💵</span> <span class="text-[14vh] leading-none">💵</span>
<p class="text-2xl text-muted-foreground">Feed bills here</p> <p class="text-2xl text-muted-foreground">Feed bills here</p>
<p v-if="context" class="text-lg text-muted-foreground"> <p v-if="context" class="text-lg text-muted-foreground">
@ -296,21 +306,29 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</div> </div>
<!-- Display QR (split-screen) --> <!-- Display QR (split-screen) -->
<div v-else-if="nestedState === 'displayingQR'" key="displayingQR" class="flex flex-1"> <div
<!-- Left panel (52%) — info --> v-else-if="nestedState === 'displayingQR'"
<div class="flex w-[52%] flex-col items-center justify-center gap-5 px-[4vw]"> key="displayingQR"
<p class="text-[2rem] font-semibold text-success">Scan to receive your sats</p> class="flex flex-1 flex-col-reverse lg:flex-row"
<p class="text-[7vh] font-bold text-bitcoin leading-tight"> >
<!-- Left panel — info -->
<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-success">
Scan to receive your sats
</p>
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
{{ formatSats(context?.satsAmount || 0) }} sats {{ formatSats(context?.satsAmount || 0) }} sats
</p> </p>
<p class="text-2xl text-muted-foreground"> <p class="text-lg lg:text-2xl text-muted-foreground">
{{ atmStore.fiatSymbol }}{{ ((context?.fiatAmount || 0) / 100).toFixed(2) }} {{ atmStore.fiatSymbol }}{{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
</p> </p>
<!-- Waiting indicator --> <!-- Waiting indicator -->
<div class="flex items-center gap-3 pt-4"> <div class="flex items-center gap-3 pt-2 lg:pt-4">
<BounceDots /> <BounceDots />
<p class="text-xl text-muted-foreground">Waiting for wallet scan...</p> <p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p>
</div> </div>
<!-- LNURL URI (web-ui only) --> <!-- LNURL URI (web-ui only) -->
@ -342,8 +360,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</div> </div>
</div> </div>
<!-- Right panel (48%) — QR code --> <!-- Right panel — QR code -->
<div class="flex w-[48%] flex-col items-center justify-center bg-foreground/5 p-6"> <div
class="flex w-full lg:w-[48%] flex-col items-center justify-center bg-foreground/5 p-4 lg:p-6"
>
<div v-if="isGeneratingLnurl && !context?.ndebitUri"> <div v-if="isGeneratingLnurl && !context?.ndebitUri">
<BounceDots class="scale-150" /> <BounceDots class="scale-150" />
</div> </div>
@ -364,9 +384,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
class="flex flex-1 flex-col items-center justify-center gap-6 p-8" 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))" style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
> >
<div class="text-[8vh]">⚠️</div> <div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-[3rem] font-bold text-destructive">Cash Already Accepted</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Cash Already Accepted</h3>
<p class="max-w-lg text-center text-2xl text-muted-foreground"> <p class="max-w-lg text-center text-base lg:text-2xl text-muted-foreground">
Your {{ atmStore.fiatSymbol Your {{ atmStore.fiatSymbol
}}{{ context ? (context.fiatAmount / 100).toLocaleString() : '' }} has been accepted and }}{{ context ? (context.fiatAmount / 100).toLocaleString() : '' }} has been accepted and
cannot be returned. Please scan the QR code to receive your Bitcoin. cannot be returned. Please scan the QR code to receive your Bitcoin.
@ -392,32 +412,32 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
<div <div
v-else-if="nestedState === 'complete'" v-else-if="nestedState === 'complete'"
key="complete" key="complete"
class="flex flex-1 items-center justify-center gap-10 p-8" class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--success) 10%, var(--background))" style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
> >
<!-- Left side — details --> <!-- Left side — details -->
<div class="flex flex-col items-center gap-5"> <div class="flex flex-col items-center gap-3 lg:gap-5">
<div class="text-[8vh]">🎉</div> <div class="text-5xl lg:text-[8vh]">🎉</div>
<h3 class="text-[3rem] font-bold text-success">Transaction Complete!</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-success">Transaction Complete!</h3>
<!-- Transaction details --> <!-- Transaction details -->
<div <div
v-if="context" v-if="context"
class="w-full max-w-md rounded-xl bg-background/60 px-8 py-6 space-y-3" class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2 lg:space-y-3"
> >
<div class="flex justify-between text-2xl"> <div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground">Inserted</span> <span class="text-muted-foreground">Inserted</span>
<span class="font-semibold" <span class="font-semibold"
>{{ atmStore.fiatSymbol }}{{ (context.fiatAmount / 100).toFixed(2) }}</span >{{ atmStore.fiatSymbol }}{{ (context.fiatAmount / 100).toFixed(2) }}</span
> >
</div> </div>
<div class="flex justify-between text-2xl"> <div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground">Rate</span> <span class="text-muted-foreground">Rate</span>
<span <span
>{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC</span >{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC</span
> >
</div> </div>
<div class="flex justify-between text-2xl"> <div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground" <span class="text-muted-foreground"
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span >Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
> >
@ -434,13 +454,13 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
> >
</div> </div>
<div class="border-t border-border my-2"></div> <div class="border-t border-border my-2"></div>
<div class="flex justify-between text-[1.8rem] font-bold"> <div class="flex justify-between text-xl lg:text-[1.8rem] font-bold">
<span class="text-success">You received</span> <span class="text-success">You received</span>
<span class="text-success">{{ formatSats(context.satsAmount) }} sats</span> <span class="text-success">{{ formatSats(context.satsAmount) }} sats</span>
</div> </div>
</div> </div>
<p class="text-xl text-muted-foreground"> <p class="text-sm lg:text-xl text-muted-foreground">
Take a photo of this screen for your receipt Take a photo of this screen for your receipt
</p> </p>
@ -462,12 +482,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
<div <div
v-else-if="nestedState === 'error'" v-else-if="nestedState === 'error'"
key="error" key="error"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8" class="flex flex-1 flex-col items-center justify-center gap-4 lg:gap-6 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))" style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
> >
<div class="text-[8vh]">⚠️</div> <div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-[3rem] font-bold text-destructive">Error</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Error</h3>
<p class="text-2xl text-muted-foreground">{{ context?.error || 'An error occurred' }}</p> <p class="text-base lg:text-2xl text-muted-foreground">
{{ context?.error || 'An error occurred' }}
</p>
<Button <Button
v-if="context && context.billsInserted.length > 0" v-if="context && context.billsInserted.length > 0"
class="w-full max-w-md bg-success text-success-foreground" class="w-full max-w-md bg-success text-success-foreground"

View file

@ -117,17 +117,21 @@ function formatFiat(cents: number): string {
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<!-- Header with warning flow accent --> <!-- Header with warning flow accent -->
<header <header
class="flex items-center px-8 py-4" class="flex items-center px-4 lg:px-8 py-2 lg:py-4"
style="background: color-mix(in srgb, var(--warning) 10%, var(--background))" style="background: color-mix(in srgb, var(--warning) 10%, var(--background))"
> >
<Button variant="ghost" size="kiosk" class="text-muted-foreground" @click="cancel" <Button
variant="ghost"
size="kiosk"
class="text-muted-foreground text-sm h-auto min-h-0 px-2 py-1 lg:text-2xl lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3"
@click="cancel"
>← Cancel</Button >← Cancel</Button
> >
<div class="flex flex-1 flex-col items-center gap-2"> <div class="flex flex-1 flex-col items-center gap-1 lg:gap-2">
<h2 class="text-[2.5rem] font-semibold">Sell Bitcoin</h2> <h2 class="text-xl lg:text-[2.5rem] font-semibold">Sell Bitcoin</h2>
<StepIndicator :steps="cashOutSteps" :current-step="currentStepIndex" /> <StepIndicator :steps="cashOutSteps" :current-step="currentStepIndex" />
</div> </div>
<div class="w-20"></div> <div class="w-16 lg:w-20"></div>
</header> </header>
<!-- Content area --> <!-- Content area -->
@ -147,16 +151,16 @@ function formatFiat(cents: number): string {
<div <div
v-else-if="nestedState === 'selectingAmount'" v-else-if="nestedState === 'selectingAmount'"
key="selectingAmount" key="selectingAmount"
class="flex flex-1 flex-col justify-center px-[8vw] py-6 gap-6" class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
> >
<!-- Denomination grid --> <!-- Denomination grid -->
<div class="grid grid-cols-2 gap-6"> <div class="grid grid-cols-2 gap-3 lg:gap-6">
<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-6 py-5" class="flex items-center justify-between rounded-lg bg-card px-3 py-3 lg:px-6 lg:py-5"
> >
<span class="text-[2.5rem] font-bold text-success" <span class="text-xl lg: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">
@ -168,7 +172,7 @@ function formatFiat(cents: number): string {
> >
- -
</Button> </Button>
<span class="w-16 text-center text-[2rem] font-medium">{{ <span class="w-10 lg:w-16 text-center text-lg lg:text-[2rem] font-medium">{{
selectionCounts[denom] || 0 selectionCounts[denom] || 0
}}</span> }}</span>
<Button <Button
@ -184,18 +188,18 @@ function formatFiat(cents: number): string {
</div> </div>
<!-- Amount summary --> <!-- Amount summary -->
<div class="text-center py-4"> <div class="text-center py-2 lg:py-4">
<p class="text-xl text-muted-foreground">You will receive</p> <p class="text-sm lg:text-xl text-muted-foreground">You will receive</p>
<p class="text-[7vh] font-bold text-success leading-tight"> <p class="text-4xl lg: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-2xl text-muted-foreground"> <p class="mt-1 lg:mt-2 text-lg lg:text-2xl text-muted-foreground">
You pay: You pay:
<Badge variant="secondary" class="ml-1 text-2xl"> <Badge variant="secondary" class="ml-1 text-lg lg: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"> <p v-if="context" class="mt-1 text-sm lg:text-lg text-muted-foreground">
Rate: Rate:
<Badge variant="secondary" class="text-lg"> <Badge variant="secondary" class="text-lg">
{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC {{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC
@ -233,7 +237,7 @@ 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-xl text-muted-foreground"> <p class="text-sm lg: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>
@ -254,25 +258,27 @@ function formatFiat(cents: number): string {
<div <div
v-else-if="nestedState === 'displayingInvoice'" v-else-if="nestedState === 'displayingInvoice'"
key="displayingInvoice" key="displayingInvoice"
class="flex flex-1" class="flex flex-1 flex-col-reverse lg:flex-row"
> >
<!-- Left panel (52%) — info --> <!-- Left panel — info -->
<div class="flex w-[52%] flex-col items-center justify-center gap-5 px-[4vw]"> <div
<p class="text-[2rem] font-semibold text-warning">Scan to Pay</p> 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-[7vh] font-bold text-bitcoin leading-tight"> >
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p>
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
{{ context ? formatSats(context.satsAmount) : 0 }} sats {{ context ? formatSats(context.satsAmount) : 0 }} sats
</p> </p>
<p class="text-2xl text-muted-foreground"> <p class="text-lg lg:text-2xl text-muted-foreground">
to receive to receive
<Badge class="bg-success text-success-foreground text-2xl"> <Badge class="bg-success text-success-foreground text-lg lg:text-2xl">
{{ atmStore.fiatSymbol }}{{ context ? formatFiat(context.fiatAmount) : '0.00' }} {{ atmStore.fiatSymbol }}{{ context ? formatFiat(context.fiatAmount) : '0.00' }}
</Badge> </Badge>
</p> </p>
<!-- Waiting indicator --> <!-- Waiting indicator -->
<div class="flex items-center gap-3 pt-4"> <div class="flex items-center gap-3 pt-2 lg:pt-4">
<BounceDots /> <BounceDots />
<p class="text-xl text-muted-foreground">Waiting for payment...</p> <p class="text-sm lg:text-xl 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) -->
@ -300,8 +306,10 @@ function formatFiat(cents: number): string {
</Alert> </Alert>
</div> </div>
<!-- Right panel (48%) — QR code --> <!-- Right panel — QR code -->
<div class="flex w-[48%] flex-col items-center justify-center bg-foreground/5 p-6"> <div
class="flex w-full lg:w-[48%] flex-col items-center justify-center bg-foreground/5 p-4 lg:p-6"
>
<div v-if="context?.invoice"> <div v-if="context?.invoice">
<QRCode :value="context.invoice" :size="380" /> <QRCode :value="context.invoice" :size="380" />
</div> </div>
@ -330,9 +338,9 @@ function formatFiat(cents: number): string {
class="flex flex-1 flex-col items-center justify-center gap-6 p-8" 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))" style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
> >
<div class="text-[8vh] text-success">✅</div> <div class="text-5xl lg:text-[8vh] text-success">✅</div>
<h3 class="text-[3rem] font-bold text-success">Cash Ready!</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-success">Cash Ready!</h3>
<p v-if="context" class="text-2xl"> <p v-if="context" class="text-lg lg:text-2xl">
Please take your Please take your
<Badge class="bg-success text-success-foreground text-2xl" <Badge class="bg-success text-success-foreground text-2xl"
>{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</Badge >{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</Badge
@ -351,30 +359,30 @@ function formatFiat(cents: number): string {
<div <div
v-else-if="nestedState === 'complete'" v-else-if="nestedState === 'complete'"
key="complete" key="complete"
class="flex flex-1 items-center justify-center gap-10 p-8" class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--success) 10%, var(--background))" style="background: color-mix(in srgb, var(--success) 10%, var(--background))"
> >
<!-- Left side — details --> <!-- Left side — details -->
<div class="flex flex-col items-center gap-5"> <div class="flex flex-col items-center gap-3 lg:gap-5">
<div class="text-[8vh]">🎉</div> <div class="text-5xl lg:text-[8vh]">🎉</div>
<h3 class="text-[3rem] font-bold text-success">Transaction Complete!</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-success">Transaction Complete!</h3>
<!-- Transaction details --> <!-- Transaction details -->
<div <div
v-if="context" v-if="context"
class="w-full max-w-md rounded-xl bg-background/60 px-8 py-6 space-y-3" class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2 lg:space-y-3"
> >
<div class="flex justify-between text-2xl"> <div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground">You paid</span> <span class="text-muted-foreground">You paid</span>
<span class="font-semibold">{{ formatSats(context.satsAmount) }} sats</span> <span class="font-semibold">{{ formatSats(context.satsAmount) }} sats</span>
</div> </div>
<div class="flex justify-between text-2xl"> <div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground">Rate</span> <span class="text-muted-foreground">Rate</span>
<span <span
>{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC</span >{{ atmStore.fiatSymbol }}{{ formatFiatPrice(context.exchangeRate) }}/BTC</span
> >
</div> </div>
<div class="flex justify-between text-2xl"> <div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground" <span class="text-muted-foreground"
>Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span >Commission ({{ (context.feePercent * 100).toFixed(1) }}%)</span
> >
@ -391,7 +399,7 @@ function formatFiat(cents: number): string {
> >
</div> </div>
<div class="border-t border-border my-2"></div> <div class="border-t border-border my-2"></div>
<div class="flex justify-between text-[1.8rem] font-bold"> <div class="flex justify-between text-xl lg:text-[1.8rem] font-bold">
<span class="text-success">Cash received</span> <span class="text-success">Cash received</span>
<span class="text-success" <span class="text-success"
>{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</span >{{ atmStore.fiatSymbol }}{{ formatFiat(context.fiatAmount) }}</span
@ -399,7 +407,7 @@ function formatFiat(cents: number): string {
</div> </div>
</div> </div>
<p class="text-xl text-muted-foreground"> <p class="text-sm lg:text-xl text-muted-foreground">
Take a photo of this screen for your receipt Take a photo of this screen for your receipt
</p> </p>
@ -421,12 +429,14 @@ function formatFiat(cents: number): string {
<div <div
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'" v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
key="error" key="error"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8" class="flex flex-1 flex-col items-center justify-center gap-4 lg:gap-6 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))" style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
> >
<div class="text-[8vh]">⚠️</div> <div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-[3rem] font-bold text-destructive">Error</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Error</h3>
<p class="text-2xl text-muted-foreground">{{ context?.error || 'An error occurred' }}</p> <p class="text-base lg: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" size="kiosk"

View file

@ -59,23 +59,31 @@ function handleCashOut() {
</script> </script>
<template> <template>
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col justify-around lg:justify-start">
<!-- Brand Zone (top ~30%) --> <!-- Brand Zone -->
<div class="flex flex-col items-center justify-center gap-2 pt-[4vh] pb-[2vh]"> <div
class="flex flex-col items-center justify-center gap-1.5 lg:gap-2 pt-6 lg:pt-[4vh] pb-2 lg:pb-[2vh]"
>
<img <img
src="/logo.png" src="/logo.png"
alt="Lamassu" alt="Lamassu"
class="h-[12vh] w-[12vh] animate-float drop-shadow-[0_0_24px_var(--primary)]" class="h-20 w-20 lg:h-[12vh] lg:w-[12vh] animate-float drop-shadow-[0_0_24px_var(--primary)]"
/> />
<h1 class="text-[5vh] font-bold text-primary leading-tight">Bitcoinmat</h1> <h1 class="text-3xl lg:text-[5vh] font-bold text-primary leading-tight">Bitcoinmat</h1>
<div class="flex flex-wrap items-center justify-center gap-3"> <div class="flex flex-wrap items-center justify-center gap-2 lg:gap-3">
<Badge variant="outline" class="border-success text-success text-lg">No KYC</Badge> <Badge variant="outline" class="border-success text-success text-[11px] lg:text-lg"
<Badge variant="outline" class="border-primary text-primary text-lg">No Registration</Badge> >No KYC</Badge
<Badge variant="outline" class="border-bitcoin text-bitcoin text-lg">Just Bitcoin</Badge> >
<Badge variant="outline" class="border-primary text-primary text-[11px] lg:text-lg"
>No Registration</Badge
>
<Badge variant="outline" class="border-bitcoin text-bitcoin text-[11px] lg:text-lg"
>Just Bitcoin</Badge
>
</div> </div>
<!-- Balance + Commission rates --> <!-- Balance + Commission rates -->
<div <div
class="mt-1 flex flex-wrap items-center justify-center gap-4 text-[2vh] text-muted-foreground" class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground"
> >
<span v-if="atmStore.balanceSats !== null"> <span v-if="atmStore.balanceSats !== null">
Available: Available:
@ -106,36 +114,39 @@ function handleCashOut() {
</div> </div>
</div> </div>
<!-- Touch Zones (fills remaining space) --> <!-- Touch Zones -->
<div class="flex flex-1 items-center justify-center gap-[8vw] pb-[4vh]"> <div
class="flex flex-col lg:flex-1 lg:flex-row items-center justify-center gap-5 lg:gap-[8vw] lg:pb-[4vh]"
>
<!-- Buy Bitcoin --> <!-- Buy Bitcoin -->
<button <button
class="flex aspect-square w-[36vh] flex-col items-center justify-center gap-4 rounded-full border-2 border-bitcoin/50 bg-bitcoin/10 transition-all active:scale-[0.97] animate-zone-glow" class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-bitcoin/50 bg-bitcoin/10 transition-all active:scale-[0.97] animate-zone-glow"
style="--glow-color: var(--bitcoin)" style="--glow-color: var(--bitcoin)"
@click="handleCashIn" @click="handleCashIn"
> >
<BitcoinIcon :size="100" class="shrink-0" /> <BitcoinIcon :size="100" class="shrink-0 w-10 h-10 lg:w-[100px] lg:h-[100px]" />
<span class="text-[3.5vh] font-bold text-bitcoin">Buy Bitcoin</span> <span class="text-base lg:text-[3.5vh] font-bold text-bitcoin">Buy Bitcoin</span>
<span class="text-[1.8vh] text-foreground/70">Insert cash, receive sats</span> <span class="text-[10px] lg:text-[1.8vh] text-foreground/70"
>Insert cash, receive sats</span
>
</button> </button>
<!-- Sell Bitcoin --> <!-- Sell Bitcoin -->
<button <button
class="flex aspect-square w-[36vh] flex-col items-center justify-center gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97] animate-zone-glow" class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97] animate-zone-glow"
style="--glow-color: var(--success)" style="--glow-color: var(--success)"
@click="handleCashOut" @click="handleCashOut"
> >
<span class="text-[8vh] leading-none">💵</span> <span class="text-4xl lg:text-[8vh] leading-none">💵</span>
<span class="text-[3.5vh] font-bold text-success">Sell Bitcoin</span> <span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span>
<span class="text-[1.8vh] text-foreground/70">Pay invoice, receive cash</span> <span class="text-[10px] lg:text-[1.8vh] text-foreground/70"
>Pay invoice, receive cash</span
>
</button> </button>
</div> </div>
<!-- Dev QR buttons --> <!-- Dev QR buttons -->
<div <div v-if="atmStore.debugMode" class="flex flex-wrap items-center justify-center gap-2 pb-2">
v-if="atmStore.debugMode"
class="absolute bottom-16 left-1/2 -translate-x-1/2 flex flex-wrap items-center justify-center gap-4"
>
<Button <Button
v-if="lndconnectUrl" v-if="lndconnectUrl"
variant="ghost" variant="ghost"
@ -207,7 +218,7 @@ function handleCashOut() {
v-if="!atmStore.debugMode && atmStore.allowMockFallback" v-if="!atmStore.debugMode && atmStore.allowMockFallback"
variant="ghost" variant="ghost"
size="kiosk" size="kiosk"
class="absolute bottom-6 right-6 text-lg text-muted-foreground" class="absolute bottom-2 right-2 lg:bottom-6 lg:right-6 h-8 min-h-0 px-2 text-xs lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:text-2xl text-muted-foreground"
@click="atmStore.toggleDebug" @click="atmStore.toggleDebug"
> >
Show Debug Show Debug