feat(machine): add markdown-driven support pages
Add support/help pages accessible via a ? button on the idle screen. Pages are driven by .md files in /var/lib/lamassu-atm/support/ — operators can customize content without rebuilding the app. Features: - Tabbed view with markdown rendering (via marked) - Standalone URLs auto-render as QR codes (scannable from phone) - Table URLs: click-to-reveal QR codes (prevents accidental scans) - Yes/No rendered as green checkmarks / red X marks - Wallet comparison table with download QR codes - FAQ with Lightning-only clarification - Support page with operator Nostr QR placeholder - Custodial vs non-custodial footnote - Large text for touchscreen accessibility - Centered layout for short-content pages Closes #36 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ca6d8c4f68
commit
d71815a15d
26 changed files with 825 additions and 2 deletions
|
|
@ -227,6 +227,29 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
|||
remediateTransaction(txid, remediatedByTxid)
|
||||
)
|
||||
|
||||
// Support pages — read .md files from /var/lib/lamassu-atm/support/
|
||||
ipcMain.handle('support:get-pages', () => {
|
||||
const supportDir = path.join(
|
||||
fs.existsSync('/var/lib/lamassu-atm') ? '/var/lib/lamassu-atm' : process.cwd(),
|
||||
'support'
|
||||
)
|
||||
if (!fs.existsSync(supportDir)) return []
|
||||
|
||||
const files = fs
|
||||
.readdirSync(supportDir)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.sort()
|
||||
return files.map((file) => {
|
||||
const content = fs.readFileSync(path.join(supportDir, file), 'utf-8')
|
||||
const titleMatch = content.match(/^#\s+(.+)/m)
|
||||
return {
|
||||
id: file.replace('.md', ''),
|
||||
title: titleMatch ? titleMatch[1] : file.replace('.md', ''),
|
||||
content,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// HAL Hardware IPC Handlers
|
||||
// HAL runs in the main process (Node.js) because it needs serialport.
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
|
||||
|
||||
// Support pages
|
||||
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
|
||||
ipcRenderer.invoke('support:get-pages'),
|
||||
|
||||
// HAL hardware (runs in main process, exposed via IPC)
|
||||
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('hal:init', config),
|
||||
|
|
@ -152,6 +156,7 @@ declare global {
|
|||
}) => Promise<void>
|
||||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||
// HAL hardware IPC
|
||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||
halDispense: (amounts: any) => Promise<any>
|
||||
|
|
|
|||
|
|
@ -27,11 +27,13 @@
|
|||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@lamassu/state-machine": "workspace:*",
|
||||
"@scure/base": "^1.2.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"marked": "^17.0.5",
|
||||
"nostr-tools": "^2.10.0",
|
||||
"pinia": "^2.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -7,8 +8,10 @@ import { Button } from '@/components/ui/button'
|
|||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const route = useRoute()
|
||||
const { current: currentTheme, themes, colorMode } = useTheme()
|
||||
const debugExpanded = ref(false)
|
||||
const isSupport = computed(() => route.path === '/support')
|
||||
// Network detected dynamically from Lightning invoice prefix
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
|
|
@ -119,8 +122,9 @@ function toggleLiveServices() {
|
|||
<template v-else>
|
||||
<router-view />
|
||||
|
||||
<!-- Connection status + network (always visible, top right) -->
|
||||
<!-- Connection status + network (hidden on support page) -->
|
||||
<div
|
||||
v-if="!isSupport"
|
||||
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"
|
||||
>
|
||||
<!-- Price and balance: only on idle -->
|
||||
|
|
|
|||
15
apps/machine/src/components/ui/accordion/Accordion.vue
Normal file
15
apps/machine/src/components/ui/accordion/Accordion.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
|
||||
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionContent } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
data-slot="accordion-content"
|
||||
v-bind="delegatedProps"
|
||||
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
>
|
||||
<div :class="cn('pt-0 pb-4', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
24
apps/machine/src/components/ui/accordion/AccordionItem.vue
Normal file
24
apps/machine/src/components/ui/accordion/AccordionItem.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-slot="slotProps"
|
||||
data-slot="accordion-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b last:border-b-0', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { AccordionHeader, AccordionTrigger } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
data-slot="accordion-trigger"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
4
apps/machine/src/components/ui/accordion/index.ts
Normal file
4
apps/machine/src/components/ui/accordion/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as AccordionContent } from './AccordionContent.vue'
|
||||
export { default as AccordionItem } from './AccordionItem.vue'
|
||||
export { default as AccordionTrigger } from './AccordionTrigger.vue'
|
||||
16
apps/machine/src/components/ui/table/Table.vue
Normal file
16
apps/machine/src/components/ui/table/Table.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="table-container" class="relative w-full overflow-auto">
|
||||
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/table/TableBody.vue
Normal file
14
apps/machine/src/components/ui/table/TableBody.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/table/TableCaption.vue
Normal file
14
apps/machine/src/components/ui/table/TableCaption.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<caption data-slot="table-caption" :class="cn('text-muted-foreground mt-4 text-sm', props.class)">
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
||||
22
apps/machine/src/components/ui/table/TableCell.vue
Normal file
22
apps/machine/src/components/ui/table/TableCell.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
32
apps/machine/src/components/ui/table/TableEmpty.vue
Normal file
32
apps/machine/src/components/ui/table/TableEmpty.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
import TableCell from './TableCell.vue'
|
||||
import TableRow from './TableRow.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
colspan?: number
|
||||
}>(),
|
||||
{
|
||||
colspan: 1,
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
17
apps/machine/src/components/ui/table/TableFooter.vue
Normal file
17
apps/machine/src/components/ui/table/TableFooter.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
||||
22
apps/machine/src/components/ui/table/TableHead.vue
Normal file
22
apps/machine/src/components/ui/table/TableHead.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
14
apps/machine/src/components/ui/table/TableHeader.vue
Normal file
14
apps/machine/src/components/ui/table/TableHeader.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
19
apps/machine/src/components/ui/table/TableRow.vue
Normal file
19
apps/machine/src/components/ui/table/TableRow.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="
|
||||
cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
9
apps/machine/src/components/ui/table/index.ts
Normal file
9
apps/machine/src/components/ui/table/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { default as Table } from './Table.vue'
|
||||
export { default as TableBody } from './TableBody.vue'
|
||||
export { default as TableCaption } from './TableCaption.vue'
|
||||
export { default as TableCell } from './TableCell.vue'
|
||||
export { default as TableEmpty } from './TableEmpty.vue'
|
||||
export { default as TableFooter } from './TableFooter.vue'
|
||||
export { default as TableHead } from './TableHead.vue'
|
||||
export { default as TableHeader } from './TableHeader.vue'
|
||||
export { default as TableRow } from './TableRow.vue'
|
||||
8
apps/machine/src/components/ui/table/utils.ts
Normal file
8
apps/machine/src/components/ui/table/utils.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { Updater } from '@tanstack/vue-table'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { isFunction } from '@tanstack/vue-table'
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||
ref.value = isFunction(updaterOrValue) ? updaterOrValue(ref.value) : updaterOrValue
|
||||
}
|
||||
|
|
@ -16,6 +16,11 @@ const router = createRouter({
|
|||
{ path: '/', name: 'idle', component: IdleView },
|
||||
{ path: '/cash-out', name: 'cash-out', component: CashOutView },
|
||||
{ path: '/cash-in', name: 'cash-in', component: CashInView },
|
||||
{
|
||||
path: '/support',
|
||||
name: 'support',
|
||||
component: () => import('./views/SupportView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
1
apps/machine/src/types/electron.d.ts
vendored
1
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -60,6 +60,7 @@ declare global {
|
|||
}) => Promise<void>
|
||||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
|
||||
// HAL hardware IPC
|
||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||
halDispense: (amounts: any) => Promise<any>
|
||||
|
|
|
|||
|
|
@ -211,6 +211,16 @@ function handleCashOut() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help button (top-left) -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl text-muted-foreground"
|
||||
@click="$router.push('/support')"
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
|
||||
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
||||
<Button
|
||||
v-if="!atmStore.debugMode && atmStore.allowMockFallback"
|
||||
|
|
|
|||
456
apps/machine/src/views/SupportView.vue
Normal file
456
apps/machine/src/views/SupportView.vue
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { marked } from 'marked'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
// Accordion available for future FAQ enhancement
|
||||
// import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { QrCode, ExternalLink } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
interface SupportPage {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const pages = ref<SupportPage[]>([])
|
||||
const activeTab = ref(0)
|
||||
|
||||
const defaultPages: SupportPage[] = [
|
||||
{
|
||||
id: 'wallets',
|
||||
title: 'Lightning Wallets',
|
||||
content: `# Lightning Wallets
|
||||
|
||||
| Wallet | Non-custodial | KYC-free | iOS | Android | |
|
||||
|--------|---------------|----------|-----|---------|--|
|
||||
| Phoenix | Yes | Yes | Yes | Yes | https://phoenix.acinq.co |
|
||||
| Wallet of Satoshi | No | Partial | Yes | Yes | https://walletofsatoshi.com |
|
||||
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
||||
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
||||
| Breez | Yes | Yes | Yes | Yes | https://breez.technology |
|
||||
| ShockWallet | No | Yes | Yes | Yes | https://wallet.aiolabs.dev |
|
||||
|
||||
Tap a QR icon to scan and download a wallet.
|
||||
|
||||
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.`,
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
title: 'FAQ',
|
||||
content: `# FAQ
|
||||
|
||||
## How do I buy Bitcoin?
|
||||
1. Tap "Buy Bitcoin"
|
||||
2. Insert cash bills
|
||||
3. Tap "Finish Inserting"
|
||||
4. Scan the LNURL-withdraw QR code with your Lightning wallet
|
||||
5. Your sats will arrive in seconds
|
||||
|
||||
## How do I sell Bitcoin?
|
||||
1. Tap "Sell Bitcoin"
|
||||
2. Select how much cash you want
|
||||
3. Scan the QR code and pay the invoice
|
||||
4. Collect your cash
|
||||
|
||||
## What is Lightning?
|
||||
Lightning is a fast payment layer (Layer 2) on top of Bitcoin. Payments are instant and fees are very low. This ATM operates exclusively on the Lightning Network — on-chain Bitcoin transactions are not currently supported.
|
||||
|
||||
## Is this anonymous?
|
||||
Yes. No ID or account required.`,
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: 'Support',
|
||||
content: `# Support
|
||||
|
||||
Need help? Scan the operator's Nostr QR code to get in touch.
|
||||
|
||||
[operator-qr-placeholder]
|
||||
|
||||
*Coming soon*
|
||||
|
||||
You can also reach us at the location where this ATM is installed.`,
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
const loaded = await window.electronAPI.getSupportPages()
|
||||
pages.value = loaded.length > 0 ? loaded : defaultPages
|
||||
} else {
|
||||
pages.value = defaultPages
|
||||
}
|
||||
})
|
||||
|
||||
const activePage = computed(() => pages.value[activeTab.value])
|
||||
|
||||
/** Parsed table row with optional URL for QR */
|
||||
interface TableRow {
|
||||
cells: string[]
|
||||
qrUrl?: string
|
||||
}
|
||||
|
||||
interface ParsedTable {
|
||||
headers: string[]
|
||||
rows: TableRow[]
|
||||
}
|
||||
|
||||
/** Segment types for rendering */
|
||||
type Segment =
|
||||
| { type: 'html'; content: string }
|
||||
| { type: 'qr'; content: string }
|
||||
| { type: 'table'; table: ParsedTable }
|
||||
| { type: 'qr-placeholder' }
|
||||
|
||||
/** Parse markdown table into structured data */
|
||||
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||
if (tableLines.length < 3) return null
|
||||
const headerLine = tableLines[0] ?? ''
|
||||
// skip separator line [1]
|
||||
const headers = headerLine
|
||||
.split('|')
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)
|
||||
const rows: TableRow[] = []
|
||||
for (let i = 2; i < tableLines.length; i++) {
|
||||
const cells = (tableLines[i] ?? '')
|
||||
.split('|')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean)
|
||||
const urlCell = cells.find((c) => /^https?:\/\//.test(c))
|
||||
rows.push({ cells, qrUrl: urlCell })
|
||||
}
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
/** Parse content into segments: html, qr, or table */
|
||||
function parseContent(md: string): Segment[] {
|
||||
const segments: Segment[] = []
|
||||
const lines = md.split('\n')
|
||||
let htmlBlock = ''
|
||||
let tableLines: string[] = []
|
||||
let inTable = false
|
||||
|
||||
function flushHtml() {
|
||||
if (htmlBlock.trim()) {
|
||||
segments.push({
|
||||
type: 'html',
|
||||
content: marked.parse(htmlBlock, { async: false }) as string,
|
||||
})
|
||||
htmlBlock = ''
|
||||
}
|
||||
}
|
||||
|
||||
function flushTable() {
|
||||
if (tableLines.length > 0) {
|
||||
const table = parseMarkdownTable(tableLines)
|
||||
if (table) {
|
||||
segments.push({ type: 'table', table })
|
||||
}
|
||||
tableLines = []
|
||||
inTable = false
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
const isTableLine = trimmed.startsWith('|') && trimmed.endsWith('|')
|
||||
|
||||
if (isTableLine) {
|
||||
if (!inTable) {
|
||||
flushHtml()
|
||||
inTable = true
|
||||
}
|
||||
tableLines.push(trimmed)
|
||||
} else {
|
||||
if (inTable) {
|
||||
flushTable()
|
||||
}
|
||||
// Standalone URL → QR card
|
||||
if (/^https?:\/\/\S+$/.test(trimmed)) {
|
||||
flushHtml()
|
||||
segments.push({ type: 'qr', content: trimmed })
|
||||
} else if (trimmed === '[operator-qr-placeholder]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'qr-placeholder' })
|
||||
} else {
|
||||
htmlBlock += line + '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inTable) flushTable()
|
||||
flushHtml()
|
||||
return segments
|
||||
}
|
||||
|
||||
const segments = computed(() => {
|
||||
if (!activePage.value) return []
|
||||
return parseContent(activePage.value.content)
|
||||
})
|
||||
|
||||
/** Check if a cell contains a URL */
|
||||
function isUrlCell(cell: string): boolean {
|
||||
return /^https?:\/\//.test(cell)
|
||||
}
|
||||
|
||||
/** Get hostname from URL */
|
||||
function hostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace('www.', '')
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Format cell value — replace Yes/No/Partial with icons */
|
||||
function formatCell(cell: string): string {
|
||||
if (cell === 'Yes') return '✓'
|
||||
if (cell === 'No') return '✗'
|
||||
if (cell === 'Partial') return '~'
|
||||
return cell
|
||||
}
|
||||
|
||||
/** Track which QR codes are expanded */
|
||||
const expandedQrs = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleQr(url: string) {
|
||||
if (expandedQrs.value.has(url)) {
|
||||
expandedQrs.value.delete(url)
|
||||
} else {
|
||||
expandedQrs.value.add(url)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide iOS/Android columns on mobile to fit the table */
|
||||
function isHiddenOnMobile(colIndex: number, totalCols: number): boolean {
|
||||
if (totalCols <= 4) return false
|
||||
// Hide columns 3 and 4 (iOS, Android) — keep Wallet, Non-custodial, KYC-free, Download
|
||||
return colIndex === 3 || colIndex === 4
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-dvh flex-col bg-background text-foreground">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center border-b border-border bg-card px-3 py-2 gap-2 relative z-50">
|
||||
<Button variant="outline" size="sm" class="shrink-0" @click="goBack">
|
||||
<span class="text-base">←</span>
|
||||
<span class="ml-1 hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
<div class="flex gap-1.5 overflow-x-auto">
|
||||
<Button
|
||||
v-for="(page, i) in pages"
|
||||
:key="page.id"
|
||||
:variant="i === activeTab ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="shrink-0 text-xs sm:text-sm"
|
||||
@click="activeTab = i"
|
||||
>
|
||||
{{ page.title }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto max-w-5xl w-full px-4 sm:px-8 lg:px-16 py-6 sm:py-8">
|
||||
<template v-for="(seg, i) in segments" :key="i">
|
||||
<!-- QR code card (standalone URL) -->
|
||||
<Card v-if="seg.type === 'qr'" class="my-4 mx-auto max-w-[240px]">
|
||||
<CardContent class="flex flex-col items-center gap-2 p-4">
|
||||
<QRCode :value="seg.content" :size="160" />
|
||||
<span class="text-xs text-muted-foreground font-mono break-all text-center">
|
||||
{{ seg.content }}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- QR placeholder (operator npub) -->
|
||||
<Card
|
||||
v-else-if="seg.type === 'qr-placeholder'"
|
||||
class="my-6 mx-auto max-w-[240px] border-dashed"
|
||||
>
|
||||
<CardContent class="flex flex-col items-center gap-3 p-8">
|
||||
<QrCode class="h-20 w-20 text-primary/40" />
|
||||
<span class="text-sm text-muted-foreground/50 text-center">
|
||||
Operator Nostr QR code
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Table with inline QR codes -->
|
||||
<div v-else-if="seg.type === 'table'" class="mb-8">
|
||||
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
v-for="(header, hi) in seg.table.headers"
|
||||
:key="hi"
|
||||
class="text-xs lg:text-lg px-2 lg:px-4"
|
||||
:class="[
|
||||
isHiddenOnMobile(hi, seg.table.headers.length) ? 'hidden lg:table-cell' : '',
|
||||
hi === 0 ? 'w-[30%]' : '',
|
||||
]"
|
||||
>
|
||||
{{ header.trim() || '' }}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(row, ri) in seg.table.rows" :key="ri">
|
||||
<TableCell
|
||||
v-for="(cell, ci) in row.cells"
|
||||
:key="ci"
|
||||
class="align-middle px-2 lg:px-4"
|
||||
:class="[
|
||||
isUrlCell(cell) ? 'py-1 lg:py-2' : 'py-2 lg:py-3',
|
||||
isHiddenOnMobile(ci, row.cells.length) ? 'hidden lg:table-cell' : '',
|
||||
]"
|
||||
>
|
||||
<!-- URL cells: link on mobile, QR toggle on desktop -->
|
||||
<div v-if="isUrlCell(cell)">
|
||||
<!-- Mobile: link icon (opens in new tab) -->
|
||||
<a
|
||||
:href="cell"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="lg:hidden inline-flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink class="h-5 w-5" />
|
||||
</a>
|
||||
<!-- Desktop: click to reveal QR -->
|
||||
<div class="hidden lg:flex flex-col items-center">
|
||||
<button
|
||||
v-if="!expandedQrs.has(cell)"
|
||||
class="flex flex-col items-center gap-1.5 px-3 py-2 rounded-xl hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
@click="toggleQr(cell)"
|
||||
>
|
||||
<QrCode class="h-10 w-10 text-muted-foreground" />
|
||||
<span class="text-[10px] text-muted-foreground/60 font-mono">
|
||||
{{ hostname(cell) }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center gap-2 py-3 cursor-pointer"
|
||||
@click="toggleQr(cell)"
|
||||
>
|
||||
<div class="rounded-xl bg-white p-3">
|
||||
<QrcodeVue
|
||||
:value="cell"
|
||||
:size="140"
|
||||
level="L"
|
||||
render-as="svg"
|
||||
background="#ffffff"
|
||||
foreground="#000000"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground/50 font-mono">
|
||||
{{ hostname(cell) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Yes/No/Partial → themed icons -->
|
||||
<span
|
||||
v-else-if="formatCell(cell) === '✓'"
|
||||
class="text-lg lg:text-2xl font-bold text-success"
|
||||
>✓</span
|
||||
>
|
||||
<span
|
||||
v-else-if="formatCell(cell) === '✗'"
|
||||
class="text-lg lg:text-2xl font-bold text-destructive"
|
||||
>✗</span
|
||||
>
|
||||
<span
|
||||
v-else-if="formatCell(cell) === '~'"
|
||||
class="text-lg lg:text-2xl font-bold text-warning"
|
||||
>~</span
|
||||
>
|
||||
<span v-else>{{ cell }}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Rendered markdown -->
|
||||
<div v-else class="support-md" v-html="seg.content"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "../style.css";
|
||||
|
||||
.support-md :deep(h1) {
|
||||
@apply text-2xl lg:text-3xl font-bold mb-4 lg:mb-6 text-foreground text-center;
|
||||
}
|
||||
.support-md :deep(h2) {
|
||||
@apply text-lg lg:text-xl font-semibold mt-6 lg:mt-8 mb-2 lg:mb-3 pb-2 border-b border-border text-foreground text-center;
|
||||
}
|
||||
.support-md :deep(h3) {
|
||||
@apply text-lg font-semibold mt-6 mb-2 text-foreground;
|
||||
}
|
||||
.support-md :deep(p) {
|
||||
@apply text-sm lg:text-base leading-relaxed mb-3 lg:mb-4 text-muted-foreground text-center;
|
||||
}
|
||||
.support-md :deep(ol) {
|
||||
@apply list-decimal pl-6 mb-4 space-y-1;
|
||||
}
|
||||
.support-md :deep(ul) {
|
||||
@apply list-disc pl-6 mb-4 space-y-1;
|
||||
}
|
||||
.support-md :deep(li) {
|
||||
@apply text-sm lg:text-base text-muted-foreground leading-relaxed;
|
||||
}
|
||||
.support-md :deep(strong) {
|
||||
@apply text-foreground font-semibold;
|
||||
}
|
||||
.support-md :deep(em) {
|
||||
@apply text-muted-foreground/50 italic;
|
||||
}
|
||||
.support-md :deep(a) {
|
||||
@apply underline;
|
||||
color: hsl(var(--bitcoin));
|
||||
}
|
||||
.support-md :deep(table) {
|
||||
@apply w-full mb-6 text-sm;
|
||||
}
|
||||
.support-md :deep(thead) {
|
||||
@apply border-b border-border;
|
||||
}
|
||||
.support-md :deep(th) {
|
||||
@apply px-4 py-3 text-left font-medium text-muted-foreground;
|
||||
}
|
||||
.support-md :deep(tbody tr) {
|
||||
@apply border-b border-border transition-colors;
|
||||
}
|
||||
.support-md :deep(tbody tr:hover) {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
.support-md :deep(td) {
|
||||
@apply px-4 py-3 text-foreground;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Y6VCaGDHDqhx8PN4rgpDI0EWEy+6oCaSnotODPK+310=";
|
||||
hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
|
|
|||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
|
|
@ -41,6 +41,9 @@ importers:
|
|||
'@scure/base':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.6
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.27(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0(vue@3.5.27(typescript@5.9.3))
|
||||
|
|
@ -56,6 +59,9 @@ importers:
|
|||
lucide-vue-next:
|
||||
specifier: ^0.563.0
|
||||
version: 0.563.0(vue@3.5.27(typescript@5.9.3))
|
||||
marked:
|
||||
specifier: ^17.0.5
|
||||
version: 17.0.5
|
||||
nostr-tools:
|
||||
specifier: ^2.10.0
|
||||
version: 2.19.4(typescript@5.9.3)
|
||||
|
|
@ -1370,9 +1376,19 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.18':
|
||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||
|
||||
'@tanstack/vue-table@8.21.3':
|
||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.18':
|
||||
resolution: {integrity: sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==}
|
||||
peerDependencies:
|
||||
|
|
@ -2518,6 +2534,11 @@ packages:
|
|||
resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
|
||||
marked@17.0.5:
|
||||
resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
matcher@3.0.0:
|
||||
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -4226,8 +4247,15 @@ snapshots:
|
|||
tailwindcss: 4.1.18
|
||||
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.18': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.3(vue@3.5.27(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
vue: 3.5.27(typescript@5.9.3)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.18(vue@3.5.27(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.18
|
||||
|
|
@ -5651,6 +5679,8 @@ snapshots:
|
|||
- bluebird
|
||||
- supports-color
|
||||
|
||||
marked@17.0.5: {}
|
||||
|
||||
matcher@3.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 4.0.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue