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:
parent
98e373c5ce
commit
3634509ed4
8 changed files with 750 additions and 525 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
29
apps/machine/src/components/ui/scroll-area/ScrollArea.vue
Normal file
29
apps/machine/src/components/ui/scroll-area/ScrollArea.vue
Normal 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>
|
||||||
33
apps/machine/src/components/ui/scroll-area/ScrollBar.vue
Normal file
33
apps/machine/src/components/ui/scroll-area/ScrollBar.vue
Normal 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>
|
||||||
2
apps/machine/src/components/ui/scroll-area/index.ts
Normal file
2
apps/machine/src/components/ui/scroll-area/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as ScrollArea } from './ScrollArea.vue'
|
||||||
|
export { default as ScrollBar } from './ScrollBar.vue'
|
||||||
|
|
@ -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,324 +197,338 @@ 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">
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
<p class="text-muted-foreground">Fetching exchange rate...</p>
|
||||||
<p class="text-muted-foreground">Fetching exchange rate...</p>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Inserting Bills -->
|
<!-- Inserting Bills -->
|
||||||
<Card
|
<Card
|
||||||
v-else-if="nestedState === 'insertingBills'"
|
v-else-if="nestedState === 'insertingBills'"
|
||||||
class="w-full max-w-96 border-0 bg-white/5"
|
class="w-full max-w-96 border-0 bg-white/5"
|
||||||
>
|
>
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle>Insert Cash</CardTitle>
|
<CardTitle>Insert Cash</CardTitle>
|
||||||
<CardDescription v-if="context">
|
<CardDescription v-if="context">
|
||||||
Rate: {{ formatSats(context.exchangeRate) }} sats/$
|
Rate: {{ formatSats(context.exchangeRate) }} sats/$
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
<!-- Bill Status -->
|
<!-- Bill Status -->
|
||||||
<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"
|
||||||
</div>
|
>${{ context.fiatAmount / 100 }}</span
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">You'll receive:</span>
|
|
||||||
<span class="font-semibold text-orange-400"
|
|
||||||
>{{ formatSats(context.satsAmount) }} sats</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Available balance indicator -->
|
|
||||||
<div v-if="context && context.availableBalance > 0" class="flex justify-between text-sm">
|
|
||||||
<span class="text-muted-foreground">Available:</span>
|
|
||||||
<span :class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'">
|
|
||||||
{{ formatSats(context.availableBalance) }} sats
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bill Acceptor Status -->
|
|
||||||
<p v-if="balanceLimitReached" class="text-center text-sm text-muted-foreground">
|
|
||||||
Maximum amount reached — press Done to continue
|
|
||||||
</p>
|
|
||||||
<p v-else class="text-center text-sm text-muted-foreground">
|
|
||||||
💵 Insert bills into the acceptor
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Dev: Simulate bill insertion -->
|
|
||||||
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
|
||||||
<AlertDescription>
|
|
||||||
<p class="mb-2 text-xs text-red-400">Dev: Simulate bills</p>
|
|
||||||
<div class="flex flex-wrap justify-center gap-2">
|
|
||||||
<Button
|
|
||||||
v-for="denom in [1, 5, 10, 20, 50, 100]"
|
|
||||||
:key="denom"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-8"
|
|
||||||
:disabled="!canAcceptBill(denom)"
|
|
||||||
@click="simulateBill(denom)"
|
|
||||||
>
|
>
|
||||||
${{ denom }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
<div class="flex justify-between">
|
||||||
</Alert>
|
<span class="text-muted-foreground">You'll receive:</span>
|
||||||
|
<span class="font-semibold text-orange-400"
|
||||||
<Button
|
>{{ formatSats(context.satsAmount) }} sats</span
|
||||||
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
|
||||||
size="lg"
|
|
||||||
:disabled="!context || context.billsInserted.length === 0"
|
|
||||||
@click="finishInserting"
|
|
||||||
>
|
|
||||||
Done Inserting
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Generating NDebit -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'generatingNdebit'"
|
|
||||||
class="w-full max-w-80 border-0 bg-white/5"
|
|
||||||
>
|
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
|
||||||
<p class="text-muted-foreground">Preparing payment code...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Display QR (unified lightning + clink) -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'displayingQR'"
|
|
||||||
class="w-full max-w-[420px] border-0 bg-white/5"
|
|
||||||
>
|
|
||||||
<CardContent class="pt-6 space-y-5">
|
|
||||||
<!-- Amount Display -->
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-muted-foreground">Withdraw</p>
|
|
||||||
<p class="text-3xl font-bold text-orange-400">
|
|
||||||
{{ formatSats(context?.satsAmount || 0) }} sats
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scan with selector -->
|
|
||||||
<div class="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>
|
|
||||||
|
|
||||||
<!-- QR Code -->
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<div class="relative">
|
|
||||||
<Skeleton
|
|
||||||
v-if="isGeneratingLnurl && !context?.ndebitUri"
|
|
||||||
class="h-[240px] w-[240px] rounded-xl"
|
|
||||||
/>
|
|
||||||
<div v-else-if="currentQrValue" class="rounded-xl overflow-hidden bg-white p-3">
|
|
||||||
<QRCode :value="currentQrValue" :size="216" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex h-[240px] w-[240px] items-center justify-center rounded-xl bg-white/10"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-muted-foreground">Generating...</p>
|
|
||||||
</div>
|
|
||||||
<!-- Lightning bolt overlay -->
|
|
||||||
<div
|
|
||||||
v-if="currentQrValue"
|
|
||||||
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center shadow-lg"
|
|
||||||
>
|
>
|
||||||
<span class="text-2xl">⚡</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mode label + truncated URI -->
|
<!-- Available balance indicator -->
|
||||||
<div class="text-center space-y-1">
|
<div
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
v-if="context && context.availableBalance > 0"
|
||||||
{{
|
class="flex justify-between text-sm"
|
||||||
qrMode === 'unified'
|
>
|
||||||
? 'Universal'
|
<span class="text-muted-foreground">Available:</span>
|
||||||
: qrMode === 'lnurl'
|
<span :class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'">
|
||||||
? 'LNURL-withdraw'
|
{{ formatSats(context.availableBalance) }} sats
|
||||||
: 'CLINK ndebit'
|
</span>
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<div v-if="currentQrValue" class="flex items-center justify-center gap-2">
|
|
||||||
<code class="text-xs text-muted-foreground truncate max-w-[280px]">
|
|
||||||
{{ currentQrValue.split('\n')[0]?.slice(0, 32) }}...
|
|
||||||
</code>
|
|
||||||
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
|
|
||||||
<span v-if="copied">✓</span>
|
|
||||||
<span v-else class="text-muted-foreground">📋</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alternative: Manual Options (collapsed) -->
|
<!-- Bill Acceptor Status -->
|
||||||
<details class="rounded-lg bg-white/5 p-3">
|
<p v-if="balanceLimitReached" class="text-center text-sm text-muted-foreground">
|
||||||
<summary class="cursor-pointer text-sm text-muted-foreground">
|
Maximum amount reached — press Done to continue
|
||||||
Alternative: Manual payment options
|
</p>
|
||||||
</summary>
|
<p v-else class="text-center text-sm text-muted-foreground">
|
||||||
<div class="mt-4 space-y-4">
|
💵 Insert bills into the acceptor
|
||||||
<!-- CLINK Debit by pubkey -->
|
</p>
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-xs font-medium text-blue-400">Enter your npub/pubkey:</p>
|
<!-- Dev: Simulate bill insertion -->
|
||||||
<div class="flex gap-2">
|
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
||||||
<Input
|
<AlertDescription>
|
||||||
v-model="pubkeyInput"
|
<p class="mb-2 text-xs text-red-400">Dev: Simulate bills</p>
|
||||||
placeholder="npub1... or hex pubkey"
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
class="flex-1 font-mono text-xs"
|
|
||||||
:disabled="isProcessing"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
|
v-for="denom in [1, 5, 10, 20, 50, 100]"
|
||||||
|
:key="denom"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!pubkeyInput.trim() || isProcessing"
|
class="h-8"
|
||||||
@click="submitDebitRequest"
|
:disabled="!canAcceptBill(denom)"
|
||||||
|
@click="simulateBill(denom)"
|
||||||
>
|
>
|
||||||
{{ atmStore.isRequestingDebit ? '...' : 'Send' }}
|
${{ denom }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<!-- Invoice paste -->
|
<Button
|
||||||
<div class="space-y-2">
|
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||||
<p class="text-xs font-medium text-orange-400">Or paste your invoice:</p>
|
size="lg"
|
||||||
<div class="flex gap-2">
|
:disabled="!context || context.billsInserted.length === 0"
|
||||||
<Input
|
@click="finishInserting"
|
||||||
v-model="invoiceInput"
|
>
|
||||||
placeholder="lnbc... or lntb..."
|
Done Inserting
|
||||||
class="flex-1 font-mono text-xs"
|
</Button>
|
||||||
:disabled="isProcessing"
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="!invoiceInput.trim() || isProcessing"
|
|
||||||
@click="submitInvoice"
|
|
||||||
>
|
|
||||||
{{ atmStore.isPayingInvoice ? '...' : 'Pay' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="atmStore.paymentError" class="text-xs text-red-400">
|
<!-- Generating NDebit -->
|
||||||
Error: {{ atmStore.paymentError }}
|
<Card
|
||||||
|
v-else-if="nestedState === 'generatingNdebit'"
|
||||||
|
class="w-full max-w-80 border-0 bg-white/5"
|
||||||
|
>
|
||||||
|
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||||
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
|
<p class="text-muted-foreground">Preparing payment code...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Display QR (unified lightning + clink) -->
|
||||||
|
<Card
|
||||||
|
v-else-if="nestedState === 'displayingQR'"
|
||||||
|
class="w-full max-w-[420px] border-0 bg-white/5"
|
||||||
|
>
|
||||||
|
<CardContent class="pt-6 space-y-5">
|
||||||
|
<!-- Amount Display -->
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">Withdraw</p>
|
||||||
|
<p class="text-3xl font-bold text-orange-400">
|
||||||
|
{{ formatSats(context?.satsAmount || 0) }} sats
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
${{ ((context?.fiatAmount || 0) / 100).toFixed(2) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Debug: Simulate payment button -->
|
<!-- Scan with selector -->
|
||||||
<div v-if="atmStore.debugMode" class="pt-2 text-center">
|
<div class="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>
|
||||||
|
|
||||||
|
<!-- QR Code -->
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="relative">
|
||||||
|
<Skeleton
|
||||||
|
v-if="isGeneratingLnurl && !context?.ndebitUri"
|
||||||
|
class="h-[240px] w-[240px] rounded-xl"
|
||||||
|
/>
|
||||||
|
<div v-else-if="currentQrValue" class="rounded-xl overflow-hidden bg-white p-3">
|
||||||
|
<QRCode :value="currentQrValue" :size="216" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-[240px] w-[240px] items-center justify-center rounded-xl bg-white/10"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-muted-foreground">Generating...</p>
|
||||||
|
</div>
|
||||||
|
<!-- Lightning bolt overlay -->
|
||||||
|
<div
|
||||||
|
v-if="currentQrValue"
|
||||||
|
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full bg-orange-500 flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">⚡</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode label + truncated URI -->
|
||||||
|
<div class="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'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div v-if="currentQrValue" class="flex items-center justify-center gap-2">
|
||||||
|
<code class="text-xs text-muted-foreground truncate max-w-[280px]">
|
||||||
|
{{ currentQrValue.split('\n')[0]?.slice(0, 32) }}...
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="sm" class="h-6 w-6 p-0" @click="copyQrValue">
|
||||||
|
<span v-if="copied">✓</span>
|
||||||
|
<span v-else class="text-muted-foreground">📋</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alternative: Manual Options (collapsed) -->
|
||||||
|
<details class="rounded-lg bg-white/5 p-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-muted-foreground">
|
||||||
|
Alternative: Manual payment 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-blue-400">Enter your npub/pubkey:</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="pubkeyInput"
|
||||||
|
placeholder="npub1... or hex pubkey"
|
||||||
|
class="flex-1 font-mono 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-orange-400">Or paste your invoice:</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="invoiceInput"
|
||||||
|
placeholder="lnbc... or lntb..."
|
||||||
|
class="flex-1 font-mono text-xs"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!invoiceInput.trim() || isProcessing"
|
||||||
|
@click="submitInvoice"
|
||||||
|
>
|
||||||
|
{{ atmStore.isPayingInvoice ? '...' : 'Pay' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="atmStore.paymentError" class="text-xs text-red-400">
|
||||||
|
Error: {{ atmStore.paymentError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Debug: Simulate payment button -->
|
||||||
|
<div v-if="atmStore.debugMode" class="pt-2 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
||||||
|
>
|
||||||
|
Dev: Skip to Success
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Ask for Receipt -->
|
||||||
|
<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">
|
||||||
|
<div class="text-6xl text-green-400">✅</div>
|
||||||
|
<CardTitle>Payment Sent!</CardTitle>
|
||||||
|
<p class="text-muted-foreground">Would you like a receipt?</p>
|
||||||
|
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
|
||||||
|
<p class="text-black">📱 Scan npub</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Sending Receipt -->
|
||||||
|
<Card
|
||||||
|
v-else-if="nestedState === 'sendingReceipt'"
|
||||||
|
class="w-full max-w-80 border-0 bg-white/5"
|
||||||
|
>
|
||||||
|
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||||
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
|
<p class="text-muted-foreground">Sending receipt...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Complete -->
|
||||||
|
<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">
|
||||||
|
<div class="text-6xl">🎉</div>
|
||||||
|
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
|
||||||
|
<p v-if="context">
|
||||||
|
You received
|
||||||
|
<Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground">Thank you!</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<Card v-else-if="nestedState === 'error'" class="w-full max-w-80 border-0 bg-red-900/30">
|
||||||
|
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||||
|
<div class="text-6xl">⚠️</div>
|
||||||
|
<CardTitle class="text-red-400">Error</CardTitle>
|
||||||
|
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
|
||||||
size="sm"
|
@click="cancel"
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
|
||||||
>
|
>
|
||||||
Dev: Skip to Success
|
Return to Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Ask for Receipt -->
|
<!-- Fallback -->
|
||||||
<Card v-else-if="nestedState === 'askForReceipt'" class="w-full max-w-80 border-0 bg-white/5">
|
<Card v-else class="w-full max-w-80 border-0 bg-white/5">
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
<CardContent class="p-8 text-center">
|
||||||
<div class="text-6xl text-green-400">✅</div>
|
<p class="text-muted-foreground">State: {{ nestedState }}</p>
|
||||||
<CardTitle>Payment Sent!</CardTitle>
|
</CardContent>
|
||||||
<p class="text-muted-foreground">Would you like a receipt?</p>
|
</Card>
|
||||||
<div class="flex h-32 w-32 items-center justify-center rounded-lg bg-white">
|
</main>
|
||||||
<p class="text-black">📱 Scan npub</p>
|
</ScrollArea>
|
||||||
</div>
|
|
||||||
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Sending Receipt -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'sendingReceipt'"
|
|
||||||
class="w-full max-w-80 border-0 bg-white/5"
|
|
||||||
>
|
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
|
||||||
<p class="text-muted-foreground">Sending receipt...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Complete -->
|
|
||||||
<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">
|
|
||||||
<div class="text-6xl">🎉</div>
|
|
||||||
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
|
|
||||||
<p v-if="context">
|
|
||||||
You received
|
|
||||||
<Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge>
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground">Thank you!</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<Card v-else-if="nestedState === 'error'" class="w-full max-w-80 border-0 bg-red-900/30">
|
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
|
||||||
<div class="text-6xl">⚠️</div>
|
|
||||||
<CardTitle class="text-red-400">Error</CardTitle>
|
|
||||||
<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">
|
|
||||||
Return to Start
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Fallback -->
|
|
||||||
<Card v-else class="w-full max-w-80 border-0 bg-white/5">
|
|
||||||
<CardContent class="p-8 text-center">
|
|
||||||
<p class="text-muted-foreground">State: {{ nestedState }}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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,247 +106,262 @@ 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">
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
<p class="text-muted-foreground">Fetching exchange rate...</p>
|
||||||
<p class="text-muted-foreground">Fetching exchange rate...</p>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Selecting Amount - Main cash-out screen -->
|
<!-- Selecting Amount - Main cash-out screen -->
|
||||||
<Card
|
<Card
|
||||||
v-else-if="nestedState === 'selectingAmount'"
|
v-else-if="nestedState === 'selectingAmount'"
|
||||||
class="w-full max-w-[520px] border-0 bg-white/5"
|
class="w-full max-w-[520px] border-0 bg-white/5"
|
||||||
>
|
>
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle>Select Cash Amount</CardTitle>
|
<CardTitle>Select Cash Amount</CardTitle>
|
||||||
<CardDescription v-if="context">
|
<CardDescription v-if="context">
|
||||||
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
|
Rate: <Badge variant="secondary">{{ formatSats(context.exchangeRate) }} sats/$</Badge>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</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"
|
||||||
:disabled="!canRemove(denom)"
|
class="h-9 w-9"
|
||||||
@click="atmStore.removeDenomination(denom)"
|
:disabled="!canRemove(denom)"
|
||||||
>
|
@click="atmStore.removeDenomination(denom)"
|
||||||
-
|
>
|
||||||
</Button>
|
-
|
||||||
<span class="w-8 text-center text-lg">{{ selectionCounts[denom] || 0 }}</span>
|
</Button>
|
||||||
<Button
|
<span class="w-8 text-center text-lg font-medium">{{
|
||||||
variant="outline"
|
selectionCounts[denom] || 0
|
||||||
size="sm"
|
}}</span>
|
||||||
:disabled="!canAdd(denom)"
|
<Button
|
||||||
@click="atmStore.addDenomination(denom)"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
+
|
class="h-9 w-9"
|
||||||
|
:disabled="!canAdd(denom)"
|
||||||
|
@click="atmStore.addDenomination(denom)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected amount summary -->
|
||||||
|
<div class="rounded-lg bg-white/10 p-6 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">You will receive</p>
|
||||||
|
<p class="text-4xl font-bold text-green-400">
|
||||||
|
${{ context ? formatFiat(context.fiatAmount) : '0.00' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
You pay:
|
||||||
|
<Badge variant="secondary" class="ml-1">
|
||||||
|
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="!context?.cashOutSelection?.length"
|
||||||
|
@click="atmStore.clearSelection()"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||||
|
:disabled="!context?.cashOutSelection?.length"
|
||||||
|
@click="atmStore.confirmAmount()"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<Alert class="border-orange-500/50 bg-orange-500/10">
|
||||||
|
<AlertDescription>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Generating Invoice -->
|
||||||
|
<Card
|
||||||
|
v-else-if="nestedState === 'generatingInvoice'"
|
||||||
|
class="w-full max-w-80 border-0 bg-white/5"
|
||||||
|
>
|
||||||
|
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||||
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
|
<p class="text-muted-foreground">Creating invoice...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Displaying Invoice - Show QR for payment -->
|
||||||
|
<Card
|
||||||
|
v-else-if="nestedState === 'displayingInvoice'"
|
||||||
|
class="w-full max-w-[480px] border-0 bg-white/5"
|
||||||
|
>
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<CardTitle>Scan to Pay</CardTitle>
|
||||||
|
<CardDescription v-if="context">
|
||||||
|
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
|
||||||
|
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-6">
|
||||||
|
<!-- Invoice QR Code -->
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
|
||||||
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Scan with any Lightning wallet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment status -->
|
||||||
|
<div class="flex flex-col items-center gap-4 py-4">
|
||||||
|
<div class="h-12 w-12 animate-pulse rounded-full bg-orange-500/20">
|
||||||
|
<div class="flex h-full items-center justify-center text-2xl">⏳</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-muted-foreground">Waiting for payment...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invoice info with copy button -->
|
||||||
|
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3">
|
||||||
|
<p class="mb-2 truncate text-center font-mono text-xs text-muted-foreground">
|
||||||
|
{{ context.invoice.slice(0, 50) }}...
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button variant="outline" size="sm" @click="copyInvoice">
|
||||||
|
{{ copyButtonText }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected amount summary -->
|
<!-- Dev controls -->
|
||||||
<div class="rounded-lg bg-white/10 p-6 text-center">
|
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
||||||
<p class="text-sm text-muted-foreground">You will receive</p>
|
<AlertDescription class="text-center">
|
||||||
<p class="text-4xl font-bold text-green-400">
|
<p class="mb-2 text-xs text-red-400">Dev: Use alice-pay or button below</p>
|
||||||
${{ context ? formatFiat(context.fiatAmount) : '0.00' }}
|
<Button
|
||||||
</p>
|
variant="outline"
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
size="sm"
|
||||||
You pay:
|
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
||||||
<Badge variant="secondary" class="ml-1">
|
>
|
||||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
Simulate Payment
|
||||||
</Badge>
|
</Button>
|
||||||
</p>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Dispensing Cash -->
|
||||||
<div class="flex gap-4">
|
<Card
|
||||||
|
v-else-if="nestedState === 'dispensingCash'"
|
||||||
|
class="w-full max-w-80 border-0 bg-white/5"
|
||||||
|
>
|
||||||
|
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||||
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
|
<p class="text-muted-foreground">Dispensing cash...</p>
|
||||||
|
<p class="text-sm text-muted-foreground">Please wait</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Cash Ready -->
|
||||||
|
<Card
|
||||||
|
v-else-if="nestedState === 'waitingForCashTaken'"
|
||||||
|
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">
|
||||||
|
<div class="text-6xl text-green-400">✅</div>
|
||||||
|
<CardTitle class="text-green-400">Cash Ready!</CardTitle>
|
||||||
|
<p v-if="context">
|
||||||
|
Please take your
|
||||||
|
<Badge class="bg-green-600 text-lg">${{ formatFiat(context.fiatAmount) }}</Badge>
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||||
class="flex-1"
|
size="lg"
|
||||||
:disabled="!context?.cashOutSelection?.length"
|
@click="confirmCashTaken"
|
||||||
@click="atmStore.clearSelection()"
|
|
||||||
>
|
>
|
||||||
Clear
|
I've Taken the Cash
|
||||||
</Button>
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Ask for Receipt -->
|
||||||
|
<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">
|
||||||
|
<CardTitle>Would you like a receipt?</CardTitle>
|
||||||
|
<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">
|
||||||
|
<p class="text-black">📱 Scan npub</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Complete -->
|
||||||
|
<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">
|
||||||
|
<div class="text-6xl">🎉</div>
|
||||||
|
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
|
||||||
|
<p class="text-muted-foreground">Thank you!</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<Card
|
||||||
|
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
|
||||||
|
class="w-full max-w-80 border-0 bg-red-900/30"
|
||||||
|
>
|
||||||
|
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
||||||
|
<div class="text-6xl">⚠️</div>
|
||||||
|
<CardTitle class="text-red-400">Error</CardTitle>
|
||||||
|
<p class="text-muted-foreground">{{ context?.error || 'An error occurred' }}</p>
|
||||||
<Button
|
<Button
|
||||||
class="flex-1 bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
|
||||||
:disabled="!context?.cashOutSelection?.length"
|
@click="cancel"
|
||||||
@click="atmStore.confirmAmount()"
|
|
||||||
>
|
>
|
||||||
Continue
|
Return to Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Instructions -->
|
<!-- Fallback -->
|
||||||
<Alert class="border-orange-500/50 bg-orange-500/10">
|
<Card v-else class="w-full max-w-80 border-0 bg-white/5">
|
||||||
<AlertDescription>
|
<CardContent class="p-8 text-center">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-muted-foreground">State: {{ nestedState }}</p>
|
||||||
Select the cash amount you want to receive. You'll pay the equivalent in Bitcoin.
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</AlertDescription>
|
</main>
|
||||||
</Alert>
|
</ScrollArea>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Generating Invoice -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'generatingInvoice'"
|
|
||||||
class="w-full max-w-80 border-0 bg-white/5"
|
|
||||||
>
|
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
|
||||||
<p class="text-muted-foreground">Creating invoice...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Displaying Invoice - Show QR for payment -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'displayingInvoice'"
|
|
||||||
class="w-full max-w-[480px] border-0 bg-white/5"
|
|
||||||
>
|
|
||||||
<CardHeader class="text-center">
|
|
||||||
<CardTitle>Scan to Pay</CardTitle>
|
|
||||||
<CardDescription v-if="context">
|
|
||||||
Pay <Badge variant="secondary">{{ formatSats(context.satsAmount) }} sats</Badge> to
|
|
||||||
receive <Badge class="bg-green-600">${{ formatFiat(context.fiatAmount) }}</Badge>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="space-y-6">
|
|
||||||
<!-- Invoice QR Code -->
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<QRCode v-if="context?.invoice" :value="context.invoice" :size="220" />
|
|
||||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
||||||
Scan with any Lightning wallet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment status -->
|
|
||||||
<div class="flex flex-col items-center gap-4 py-4">
|
|
||||||
<div class="h-12 w-12 animate-pulse rounded-full bg-orange-500/20">
|
|
||||||
<div class="flex h-full items-center justify-center text-2xl">⏳</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-center text-muted-foreground">Waiting for payment...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoice info with copy button -->
|
|
||||||
<div v-if="context?.invoice" class="rounded-lg bg-white/5 p-3">
|
|
||||||
<p class="mb-2 truncate text-center font-mono text-xs text-muted-foreground">
|
|
||||||
{{ context.invoice.slice(0, 50) }}...
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Button variant="outline" size="sm" @click="copyInvoice">
|
|
||||||
{{ copyButtonText }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dev controls -->
|
|
||||||
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
|
|
||||||
<AlertDescription class="text-center">
|
|
||||||
<p class="mb-2 text-xs text-red-400">Dev: Use alice-pay or button below</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
@click="atmStore.paymentReceived('mock-preimage-' + Date.now())"
|
|
||||||
>
|
|
||||||
Simulate Payment
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Dispensing Cash -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'dispensingCash'"
|
|
||||||
class="w-full max-w-80 border-0 bg-white/5"
|
|
||||||
>
|
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8">
|
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
|
||||||
<p class="text-muted-foreground">Dispensing cash...</p>
|
|
||||||
<p class="text-sm text-muted-foreground">Please wait</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Cash Ready -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'waitingForCashTaken'"
|
|
||||||
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">
|
|
||||||
<div class="text-6xl text-green-400">✅</div>
|
|
||||||
<CardTitle class="text-green-400">Cash Ready!</CardTitle>
|
|
||||||
<p v-if="context">
|
|
||||||
Please take your
|
|
||||||
<Badge class="bg-green-600 text-lg">${{ formatFiat(context.fiatAmount) }}</Badge>
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
|
||||||
size="lg"
|
|
||||||
@click="confirmCashTaken"
|
|
||||||
>
|
|
||||||
I've Taken the Cash
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Ask for Receipt -->
|
|
||||||
<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">
|
|
||||||
<CardTitle>Would you like a receipt?</CardTitle>
|
|
||||||
<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">
|
|
||||||
<p class="text-black">📱 Scan npub</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" @click="skipReceipt">No Thanks</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Complete -->
|
|
||||||
<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">
|
|
||||||
<div class="text-6xl">🎉</div>
|
|
||||||
<CardTitle class="text-green-400">Transaction Complete!</CardTitle>
|
|
||||||
<p class="text-muted-foreground">Thank you!</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<Card
|
|
||||||
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
|
|
||||||
class="w-full max-w-80 border-0 bg-red-900/30"
|
|
||||||
>
|
|
||||||
<CardContent class="flex flex-col items-center gap-4 p-8 text-center">
|
|
||||||
<div class="text-6xl">⚠️</div>
|
|
||||||
<CardTitle class="text-red-400">Error</CardTitle>
|
|
||||||
<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">
|
|
||||||
Return to Start
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Fallback -->
|
|
||||||
<Card v-else class="w-full max-w-80 border-0 bg-white/5">
|
|
||||||
<CardContent class="p-8 text-center">
|
|
||||||
<p class="text-muted-foreground">State: {{ nestedState }}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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
121
docs/business-model.md
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue