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:
Patrick Mulligan 2026-03-28 14:11:54 -04:00
commit d71815a15d
26 changed files with 825 additions and 2 deletions

View file

@ -227,6 +227,29 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
remediateTransaction(txid, remediatedByTxid) 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 Hardware IPC Handlers
// HAL runs in the main process (Node.js) because it needs serialport. // HAL runs in the main process (Node.js) because it needs serialport.

View file

@ -79,6 +79,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> => remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid), 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) // HAL hardware (runs in main process, exposed via IPC)
halInit: (config: any): Promise<{ success: boolean; error?: string }> => halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('hal:init', config), ipcRenderer.invoke('hal:init', config),
@ -152,6 +156,7 @@ declare global {
}) => Promise<void> }) => Promise<void>
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
// HAL hardware IPC // HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }> halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any> halDispense: (amounts: any) => Promise<any>

View file

@ -27,11 +27,13 @@
"@lamassu/nostr-client": "workspace:*", "@lamassu/nostr-client": "workspace:*",
"@lamassu/state-machine": "workspace:*", "@lamassu/state-machine": "workspace:*",
"@scure/base": "^1.2.0", "@scure/base": "^1.2.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"marked": "^17.0.5",
"nostr-tools": "^2.10.0", "nostr-tools": "^2.10.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from 'vue' import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -7,8 +8,10 @@ import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next' import { Sun, Moon } from 'lucide-vue-next'
const atmStore = useAtmStore() const atmStore = useAtmStore()
const route = useRoute()
const { current: currentTheme, themes, colorMode } = useTheme() const { current: currentTheme, themes, colorMode } = useTheme()
const debugExpanded = ref(false) const debugExpanded = ref(false)
const isSupport = computed(() => route.path === '/support')
// Network detected dynamically from Lightning invoice prefix // Network detected dynamically from Lightning invoice prefix
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
@ -119,8 +122,9 @@ function toggleLiveServices() {
<template v-else> <template v-else>
<router-view /> <router-view />
<!-- Connection status + network (always visible, top right) --> <!-- Connection status + network (hidden on support page) -->
<div <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" 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 --> <!-- Price and balance: only on idle -->

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View 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'

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View 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
}

View file

@ -16,6 +16,11 @@ const router = createRouter({
{ path: '/', name: 'idle', component: IdleView }, { path: '/', name: 'idle', component: IdleView },
{ path: '/cash-out', name: 'cash-out', component: CashOutView }, { path: '/cash-out', name: 'cash-out', component: CashOutView },
{ path: '/cash-in', name: 'cash-in', component: CashInView }, { path: '/cash-in', name: 'cash-in', component: CashInView },
{
path: '/support',
name: 'support',
component: () => import('./views/SupportView.vue'),
},
], ],
}) })

View file

@ -60,6 +60,7 @@ declare global {
}) => Promise<void> }) => Promise<void>
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
// HAL hardware IPC // HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }> halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any> halDispense: (amounts: any) => Promise<any>

View file

@ -211,6 +211,16 @@ function handleCashOut() {
</div> </div>
</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) --> <!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
<Button <Button
v-if="!atmStore.debugMode && atmStore.allowMockFallback" v-if="!atmStore.debugMode && atmStore.allowMockFallback"

View 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">&larr;</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>

View file

@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
inherit (finalAttrs) pname version src pnpmWorkspaces; inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm; inherit pnpm;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-Y6VCaGDHDqhx8PN4rgpDI0EWEy+6oCaSnotODPK+310="; hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [

30
pnpm-lock.yaml generated
View file

@ -41,6 +41,9 @@ importers:
'@scure/base': '@scure/base':
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.6 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': '@vueuse/core':
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0(vue@3.5.27(typescript@5.9.3)) version: 14.1.0(vue@3.5.27(typescript@5.9.3))
@ -56,6 +59,9 @@ importers:
lucide-vue-next: lucide-vue-next:
specifier: ^0.563.0 specifier: ^0.563.0
version: 0.563.0(vue@3.5.27(typescript@5.9.3)) version: 0.563.0(vue@3.5.27(typescript@5.9.3))
marked:
specifier: ^17.0.5
version: 17.0.5
nostr-tools: nostr-tools:
specifier: ^2.10.0 specifier: ^2.10.0
version: 2.19.4(typescript@5.9.3) version: 2.19.4(typescript@5.9.3)
@ -1370,9 +1376,19 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 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': '@tanstack/virtual-core@3.13.18':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} 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': '@tanstack/vue-virtual@3.13.18':
resolution: {integrity: sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==} resolution: {integrity: sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==}
peerDependencies: peerDependencies:
@ -2518,6 +2534,11 @@ packages:
resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} 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: matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4226,8 +4247,15 @@ snapshots:
tailwindcss: 4.1.18 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) 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/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))': '@tanstack/vue-virtual@3.13.18(vue@3.5.27(typescript@5.9.3))':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.18 '@tanstack/virtual-core': 3.13.18
@ -5651,6 +5679,8 @@ snapshots:
- bluebird - bluebird
- supports-color - supports-color
marked@17.0.5: {}
matcher@3.0.0: matcher@3.0.0:
dependencies: dependencies:
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0