fix(machine): mobile UI polish and shadcn-vue ScrollArea

- Use h-dvh instead of h-screen for mobile browser viewport
- Add ScrollArea component (shadcn-vue) to replace native scrollbars
- Move Hide Debug into debug bar row, show Show Debug only when off
- Responsive cash-out denomination grid (single col on mobile)
- Add safe-area padding for mobile gesture bar
- Add business model documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-20 14:44:39 -05:00
commit 3634509ed4
8 changed files with 750 additions and 525 deletions

View file

@ -31,9 +31,7 @@ function toggleLiveServices() {
</script> </script>
<template> <template>
<div <div class="flex h-dvh w-screen flex-col overflow-hidden bg-background font-sans text-foreground">
class="flex h-screen w-screen flex-col overflow-hidden bg-background font-sans text-foreground"
>
<router-view /> <router-view />
<!-- Connection status + balance (top right) --> <!-- Connection status + balance (top right) -->
@ -73,7 +71,7 @@ function toggleLiveServices() {
<!-- Debug overlay (dev only) --> <!-- Debug overlay (dev only) -->
<div <div
v-if="atmStore.debugMode" v-if="atmStore.debugMode"
class="shrink-0 bg-popover 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"> <div class="flex flex-wrap items-center gap-2 px-4 py-2 sm:gap-4">
<Button <Button
@ -100,6 +98,14 @@ function toggleLiveServices() {
{{ theme.label }} {{ theme.label }}
</Button> </Button>
</div> </div>
<Button
variant="ghost"
size="sm"
class="ml-auto h-6 text-xs text-muted-foreground"
@click="atmStore.toggleDebug"
>
Hide Debug
</Button>
</div> </div>
<div v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4"> <div v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4">
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre> <pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'reka-ui'
import { cn } from '@/lib/utils'
import ScrollBar from './ScrollBar.vue'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ScrollAreaRoot
data-slot="scroll-area"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<ScrollAreaViewport
data-slot="scroll-area-viewport"
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ScrollAreaScrollbarProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(),
{
orientation: 'vertical',
}
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
v-bind="delegatedProps"
:class="
cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
props.class
)
"
>
<ScrollAreaThumb data-slot="scroll-area-thumb" class="bg-border relative flex-1 rounded-full" />
</ScrollAreaScrollbar>
</template>

View file

@ -0,0 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

View file

@ -8,6 +8,7 @@ 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 { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
import BitcoinIcon from '@/components/BitcoinIcon.vue' import BitcoinIcon from '@/components/BitcoinIcon.vue'
@ -196,9 +197,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</header> </header>
<!-- Content --> <!-- Content -->
<main <ScrollArea class="min-h-0 flex-1">
class="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 sm:items-center sm:p-8" <main class="flex min-h-0 flex-1 items-start justify-center p-4 sm:items-center sm:p-8">
>
<!-- Fetching Rate --> <!-- Fetching Rate -->
<Card v-if="nestedState === 'fetchingRate'" class="w-full max-w-80 border-0 bg-white/5"> <Card v-if="nestedState === 'fetchingRate'" class="w-full max-w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8"> <CardContent class="flex flex-col items-center gap-4 p-8">
@ -223,7 +223,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<div v-if="context" class="rounded-lg bg-white/5 p-4"> <div v-if="context" class="rounded-lg bg-white/5 p-4">
<div class="mb-2 flex justify-between"> <div class="mb-2 flex justify-between">
<span class="text-muted-foreground">Inserted:</span> <span class="text-muted-foreground">Inserted:</span>
<span class="text-2xl font-bold text-green-400">${{ context.fiatAmount / 100 }}</span> <span class="text-2xl font-bold text-green-400"
>${{ context.fiatAmount / 100 }}</span
>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-muted-foreground">You'll receive:</span> <span class="text-muted-foreground">You'll receive:</span>
@ -234,7 +236,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</div> </div>
<!-- Available balance indicator --> <!-- Available balance indicator -->
<div v-if="context && context.availableBalance > 0" class="flex justify-between text-sm"> <div
v-if="context && context.availableBalance > 0"
class="flex justify-between text-sm"
>
<span class="text-muted-foreground">Available:</span> <span class="text-muted-foreground">Available:</span>
<span :class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'"> <span :class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'">
{{ formatSats(context.availableBalance) }} sats {{ formatSats(context.availableBalance) }} sats
@ -460,7 +465,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</Card> </Card>
<!-- Ask for Receipt --> <!-- Ask for Receipt -->
<Card v-else-if="nestedState === 'askForReceipt'" class="w-full max-w-80 border-0 bg-white/5"> <Card
v-else-if="nestedState === 'askForReceipt'"
class="w-full max-w-80 border-0 bg-white/5"
>
<CardContent class="flex flex-col items-center gap-4 p-8"> <CardContent class="flex flex-col items-center gap-4 p-8">
<div class="text-6xl text-green-400">✅</div> <div class="text-6xl text-green-400">✅</div>
<CardTitle>Payment Sent!</CardTitle> <CardTitle>Payment Sent!</CardTitle>
@ -484,7 +492,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</Card> </Card>
<!-- Complete --> <!-- Complete -->
<Card v-else-if="nestedState === 'complete'" class="w-full max-w-80 border-0 bg-green-900/30"> <Card
v-else-if="nestedState === 'complete'"
class="w-full max-w-80 border-0 bg-green-900/30"
>
<CardContent class="flex flex-col items-center gap-4 p-8 text-center"> <CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl">🎉</div> <div class="text-6xl">🎉</div>
<CardTitle class="text-green-400">Transaction Complete!</CardTitle> <CardTitle class="text-green-400">Transaction Complete!</CardTitle>
@ -502,7 +513,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<div class="text-6xl">⚠️</div> <div class="text-6xl">⚠️</div>
<CardTitle class="text-red-400">Error</CardTitle> <CardTitle class="text-red-400">Error</CardTitle>
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p> <p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
<Button class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" @click="cancel"> <Button
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
@click="cancel"
>
Return to Start Return to Start
</Button> </Button>
</CardContent> </CardContent>
@ -515,5 +529,6 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</CardContent> </CardContent>
</Card> </Card>
</main> </main>
</ScrollArea>
</div> </div>
</template> </template>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
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 { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import QRCode from '@/components/QRCode.vue' import QRCode from '@/components/QRCode.vue'
const router = useRouter() const router = useRouter()
@ -105,9 +106,8 @@ function formatFiat(cents: number): string {
</header> </header>
<!-- Content --> <!-- Content -->
<main <ScrollArea class="min-h-0 flex-1">
class="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 sm:items-center sm:p-8" <main class="flex min-h-0 flex-1 items-start justify-center p-4 sm:items-center sm:p-8">
>
<!-- Fetching Rate --> <!-- Fetching Rate -->
<Card v-if="nestedState === 'fetchingRate'" class="w-full max-w-80 border-0 bg-white/5"> <Card v-if="nestedState === 'fetchingRate'" class="w-full max-w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8"> <CardContent class="flex flex-col items-center gap-4 p-8">
@ -129,26 +129,30 @@ function formatFiat(cents: number): string {
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
<!-- Denomination buttons --> <!-- Denomination buttons -->
<div class="grid grid-cols-2 gap-4"> <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-white/5 p-4" class="flex items-center justify-between rounded-lg bg-white/5 px-4 py-3 sm:p-4"
> >
<span class="text-2xl font-bold text-green-400">${{ denom }}</span> <span class="text-xl font-bold text-green-400 sm:text-2xl">${{ denom }}</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
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">{{ selectionCounts[denom] || 0 }}</span> <span class="w-8 text-center text-lg font-medium">{{
selectionCounts[denom] || 0
}}</span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="h-9 w-9"
:disabled="!canAdd(denom)" :disabled="!canAdd(denom)"
@click="atmStore.addDenomination(denom)" @click="atmStore.addDenomination(denom)"
> >
@ -305,10 +309,15 @@ function formatFiat(cents: number): string {
</Card> </Card>
<!-- Ask for Receipt --> <!-- Ask for Receipt -->
<Card v-else-if="nestedState === 'askForReceipt'" class="w-full max-w-80 border-0 bg-white/5"> <Card
v-else-if="nestedState === 'askForReceipt'"
class="w-full max-w-80 border-0 bg-white/5"
>
<CardContent class="flex flex-col items-center gap-4 p-8"> <CardContent class="flex flex-col items-center gap-4 p-8">
<CardTitle>Would you like a receipt?</CardTitle> <CardTitle>Would you like a receipt?</CardTitle>
<p class="text-sm text-muted-foreground">Scan your npub to receive a Nostr DM receipt</p> <p class="text-sm 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-white"> <div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
<p class="text-black">📱 Scan npub</p> <p class="text-black">📱 Scan npub</p>
</div> </div>
@ -317,7 +326,10 @@ function formatFiat(cents: number): string {
</Card> </Card>
<!-- Complete --> <!-- Complete -->
<Card v-else-if="nestedState === 'complete'" class="w-full max-w-80 border-0 bg-green-900/30"> <Card
v-else-if="nestedState === 'complete'"
class="w-full max-w-80 border-0 bg-green-900/30"
>
<CardContent class="flex flex-col items-center gap-4 p-8 text-center"> <CardContent class="flex flex-col items-center gap-4 p-8 text-center">
<div class="text-6xl">🎉</div> <div class="text-6xl">🎉</div>
<CardTitle class="text-green-400">Transaction Complete!</CardTitle> <CardTitle class="text-green-400">Transaction Complete!</CardTitle>
@ -334,7 +346,10 @@ function formatFiat(cents: number): string {
<div class="text-6xl">⚠️</div> <div class="text-6xl">⚠️</div>
<CardTitle class="text-red-400">Error</CardTitle> <CardTitle class="text-red-400">Error</CardTitle>
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p> <p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
<Button class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black" @click="cancel"> <Button
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
@click="cancel"
>
Return to Start Return to Start
</Button> </Button>
</CardContent> </CardContent>
@ -347,5 +362,6 @@ function formatFiat(cents: number): string {
</CardContent> </CardContent>
</Card> </Card>
</main> </main>
</ScrollArea>
</div> </div>
</template> </template>

View file

@ -59,7 +59,9 @@ function handleCashOut() {
</script> </script>
<template> <template>
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-4 sm:gap-6 md:gap-8 md:p-8"> <div
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 overflow-auto p-4 sm:gap-6 md:gap-8 md:p-8"
>
<!-- Logo --> <!-- Logo -->
<div class="flex flex-col items-center gap-1 sm:gap-2"> <div class="flex flex-col items-center gap-1 sm:gap-2">
<img <img
@ -187,14 +189,15 @@ function handleCashOut() {
</div> </div>
</div> </div>
<!-- Debug toggle --> <!-- Debug toggle (only visible when debug bar is hidden) -->
<Button <Button
v-if="!atmStore.debugMode"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="absolute bottom-4 right-4 text-xs text-muted-foreground" class="absolute bottom-4 right-4 text-xs text-muted-foreground"
@click="atmStore.toggleDebug" @click="atmStore.toggleDebug"
> >
{{ atmStore.debugMode ? 'Hide Debug' : 'Show Debug' }} Show Debug
</Button> </Button>
</div> </div>
</template> </template>

121
docs/business-model.md Normal file
View file

@ -0,0 +1,121 @@
# Business Model
How we place ATMs at locations and make them sustainable for everyone involved.
## Principles
- **No KYC** — more users, less friction, preserves privacy
- **Good energy locations** — we seek places with community trust and genuine intent
- **Bitcoin-native infrastructure** — Lightning, Nostr, Cashu — no legacy payment rails
- **Operator sovereignty** — locations can choose how much they want to manage themselves
## Deployment Models
### Managed (we run it)
We own the machine and handle all operations. The location provides power, internet, and floor space.
| Aspect | Details |
| ------------------- | ----------------------------------------- |
| Machine ownership | Us |
| Liquidity | Our Lightning channels + cash servicing |
| Software updates | Us (remote via Nostr) |
| Cash management | Us (periodic servicing visits) |
| Fee configuration | Us |
| Revenue to location | Fixed monthly hosting fee OR % of tx fees |
Best for locations that want passive income with zero involvement.
### Self-Sovereign (they run it)
The location buys (or finances) the machine and runs their own stack.
| Aspect | Details |
| ----------------- | ---------------------------------------- |
| Machine ownership | Them |
| Liquidity | Their own Lightning node + Lightning.Pub |
| Software updates | Them (we provide releases) |
| Cash management | Them |
| Fee configuration | Them |
| Revenue | 100% theirs |
Best for bitcoiners who want to run infrastructure and keep full control. We provide the software, hardware, and support.
### Machine Financing
For the self-sovereign model, we can offer financing:
- We purchase the machine upfront
- Location pays it off over time (from ATM revenue)
- Once paid off, they own it outright
- We provide support during the financing period
## Revenue Models
### Fixed Hosting Fee
- We pay the location a flat monthly fee for hosting
- Simpler accounting, predictable income for location
- We take all transaction fee revenue
- Works well for locations that just want passive income
### Profit Sharing
- Location receives a % of transaction fees
- Aligns incentives — they benefit from higher volume
- More attractive to engaged locations
- Requires transparent reporting (could be on-chain/Nostr-verifiable)
### Foot Traffic Value
Beyond direct revenue, the ATM provides:
- People visit specifically to use the ATM, then browse/buy at the location
- Bitcoin-friendly branding signals to the community
- Conversation starter — draws curious newcomers
- Community hub potential — meetup anchor point
## Location Criteria
We're selective about where we place machines. The goal is to find locations with **good energy** — places where the community is genuine and the environment discourages misuse.
### Ideal locations
- Coffee shops, cafes, local restaurants
- Co-working spaces, maker spaces
- Bitcoin meetup venues, hackerspaces
- Surf shops, yoga studios, wellness centers
- Local/independent businesses (not chains)
- Places where the owner is curious about or already into Bitcoin
### What we look for
- Owner alignment — understands or wants to understand Bitcoin
- Community trust — regulars know each other, self-policing
- Foot traffic — enough people to make the machine worthwhile
- Stable internet + power
- Visible but not exposed — inside the business, not on the street
### What we avoid
- High-crime areas
- Anonymous/transient locations (gas stations on highways, etc.)
- Businesses with no community connection
- Anywhere the owner is purely financially motivated with no alignment
## Technical Requirements
For the managed model to work at scale, we need:
- **Remote monitoring** — machine status, cash levels, error alerts over [[clink-protocol|CLINK]]/Nostr
- **Operator dashboard** — manage cassettes, view transactions, update config remotely
- **Multi-machine support** — one Lightning.Pub instance serving multiple ATMs
- **Fee configuration** — per-machine fee % set by operator
- **Availability broadcast** — public Nostr events indicating cash-in/cash-out availability (see [[ndebit-cash-in-flow|cash-in flow]])
## Related
- [[device-configuration]] — hardware setup for different machine models
- [[machine-installation]] — NixOS installation on ATM hardware
- [[clink-protocol]] — CLINK protocol for ATM-wallet communication
- [[ndebit-cash-in-flow]] — cash-in payment flow