feat(activities): backend-truth counts + scanned list, tabs + popup result
The Scan Tickets page now sources its counts strip and scanned-ticket
roster from the new `events_list_event_tickets` RPC instead of a
per-device localStorage cache. The previous design diverged the
moment a second organizer scanned, or the operator switched from
mobile to laptop, or refreshed in incognito — backend truth keeps
all sessions consistent.
Webapp-side changes:
- useTicketScanner now exposes `eventStats` (sold / registered /
remaining + per-ticket roster), `statsLoading`, `statsError`, and
`refreshStats()`. Initial load on mount, refresh after every
decode (success or failure) so the UI reflects state seconds
after a scan lands.
- localStorage cache demoted to silent decode dedup only. The
Clear-list button + its confirm dialog are gone — the cache
isn't authoritative state to clear anymore.
- ScanTicketsPage gets two tabs: Scanner (camera + result) and
Scanned ({count} from backend). Counts strip up top reads from
`eventStats` (with the nostr-event `tickets_sold` tag as a
fallback before the RPC roundtrip completes). A manual Refresh
button in the top bar covers the rare case where a second device
scans during your session.
- Result of each scan now lands as a full-viewport tap-to-dismiss
overlay (success green / warning amber / destructive red) so
the door operator can't skim past it on a busy entry.
Depends on aiolabs/events v1.6.1-aio.3 (already in the catalog).
This commit is contained in:
parent
815bc2d15f
commit
b4baad0d82
2 changed files with 254 additions and 125 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, onMounted, type Ref } from 'vue'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { NostrTransportService } from '@/modules/base/services/NostrTransportService'
|
import type { NostrTransportService } from '@/modules/base/services/NostrTransportService'
|
||||||
|
|
@ -21,16 +21,34 @@ export interface ScanRecord {
|
||||||
registeredAt: string
|
registeredAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A paid ticket as returned by `events_list_event_tickets`. */
|
||||||
|
export interface EventTicket {
|
||||||
|
id: string
|
||||||
|
name?: string | null
|
||||||
|
registered: boolean
|
||||||
|
registeredAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts + roster snapshot for the event, sourced from the backend. */
|
||||||
|
export interface EventStats {
|
||||||
|
sold: number
|
||||||
|
registered: number
|
||||||
|
remaining: number
|
||||||
|
tickets: EventTicket[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful scanner driver. Owns the camera lifecycle (delegated to
|
* Stateful scanner driver. Owns the camera lifecycle (delegated to
|
||||||
* useQRScanner upstream), the QR decode, the
|
* useQRScanner upstream), the QR decode, the
|
||||||
* `events_ticket_register` RPC call, and a session-local scanned
|
* `events_ticket_register` RPC call, an authoritative event roster
|
||||||
* list persisted to localStorage so a page refresh doesn't ask the
|
* fetched from `events_list_event_tickets`, and a session-local
|
||||||
* organizer to rescan tickets they already counted.
|
* dedup cache.
|
||||||
*
|
*
|
||||||
* Mirrors the LNbits admin Quasar register page's
|
* Counts + the displayed scanned list come from the backend so the
|
||||||
* `events_scanned_<eventId>` localStorage key with the
|
* UI agrees with reality even when a second organizer is scanning
|
||||||
* `activities_scanned_<id>` prefix.
|
* on another device. The localStorage cache is kept as a silent
|
||||||
|
* dedup so the 5-fps decode loop doesn't re-fire the RPC on a QR
|
||||||
|
* the camera held in frame for multiple ticks.
|
||||||
*/
|
*/
|
||||||
export function useTicketScanner(activityId: Ref<string>) {
|
export function useTicketScanner(activityId: Ref<string>) {
|
||||||
const transport = injectService<NostrTransportService>(
|
const transport = injectService<NostrTransportService>(
|
||||||
|
|
@ -50,6 +68,11 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
* just succeeded.
|
* just succeeded.
|
||||||
*/
|
*/
|
||||||
const isPaused = ref(false)
|
const isPaused = ref(false)
|
||||||
|
/** Server-authoritative counts + per-ticket registered status. */
|
||||||
|
const eventStats = ref<EventStats | null>(null)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
const statsError = ref<string | null>(null)
|
||||||
|
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
|
||||||
const scanned = useLocalStorage<ScanRecord[]>(
|
const scanned = useLocalStorage<ScanRecord[]>(
|
||||||
() => `activities_scanned_${activityId.value}`,
|
() => `activities_scanned_${activityId.value}`,
|
||||||
[],
|
[],
|
||||||
|
|
@ -61,6 +84,40 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
: qrText
|
: qrText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshStats(): Promise<void> {
|
||||||
|
if (!activityId.value) return
|
||||||
|
statsLoading.value = true
|
||||||
|
statsError.value = null
|
||||||
|
try {
|
||||||
|
const data = await transport.call<{
|
||||||
|
sold: number
|
||||||
|
registered: number
|
||||||
|
remaining: number
|
||||||
|
tickets: Array<{
|
||||||
|
id: string
|
||||||
|
name?: string | null
|
||||||
|
registered: boolean
|
||||||
|
registered_at: string | null
|
||||||
|
}>
|
||||||
|
}>('events_list_event_tickets', { event_id: activityId.value })
|
||||||
|
eventStats.value = {
|
||||||
|
sold: data.sold,
|
||||||
|
registered: data.registered,
|
||||||
|
remaining: data.remaining,
|
||||||
|
tickets: data.tickets.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name ?? null,
|
||||||
|
registered: t.registered,
|
||||||
|
registeredAt: t.registered_at,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statsError.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onDecode(qrText: string): Promise<void> {
|
async function onDecode(qrText: string): Promise<void> {
|
||||||
if (isProcessing.value || isPaused.value) return
|
if (isProcessing.value || isPaused.value) return
|
||||||
const ticketId = parseTicketId(qrText).trim()
|
const ticketId = parseTicketId(qrText).trim()
|
||||||
|
|
@ -107,6 +164,9 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
// banner and can correct (let the attendee in, deny entry,
|
// banner and can correct (let the attendee in, deny entry,
|
||||||
// etc.) before the next QR comes into frame.
|
// etc.) before the next QR comes into frame.
|
||||||
isPaused.value = true
|
isPaused.value = true
|
||||||
|
// Refresh the roster so the counts strip + scanned tab reflect
|
||||||
|
// the new state. Fire-and-forget — UI doesn't block on it.
|
||||||
|
void refreshStats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,11 +181,19 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
isPaused.value = false
|
isPaused.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refreshStats()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isPaused,
|
isPaused,
|
||||||
lastScan,
|
lastScan,
|
||||||
scanned,
|
scanned,
|
||||||
|
eventStats,
|
||||||
|
statsLoading,
|
||||||
|
statsError,
|
||||||
|
refreshStats,
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
clearScanned,
|
clearScanned,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2, ScanLine } from 'lucide-vue-next'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Ticket,
|
||||||
|
ScanLine,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
|
|
@ -21,13 +29,15 @@ const {
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isPaused,
|
isPaused,
|
||||||
lastScan,
|
lastScan,
|
||||||
scanned,
|
eventStats,
|
||||||
|
statsLoading,
|
||||||
|
refreshStats,
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
clearScanned,
|
|
||||||
} = useTicketScanner(activityId)
|
} = useTicketScanner(activityId)
|
||||||
|
|
||||||
const scannerOpen = ref(true)
|
const scannerOpen = ref(true)
|
||||||
|
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||||
|
|
||||||
const lastScanVariant = computed(() => {
|
const lastScanVariant = computed(() => {
|
||||||
switch (lastScan.value?.status) {
|
switch (lastScan.value?.status) {
|
||||||
|
|
@ -42,6 +52,22 @@ const lastScanVariant = computed(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Backend-authoritative roster. Falls back to the activity nostr
|
||||||
|
// event's `tickets_sold` tag if the RPC hasn't completed yet.
|
||||||
|
const soldCount = computed(
|
||||||
|
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
|
||||||
|
)
|
||||||
|
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
|
||||||
|
const remainingCount = computed(() => {
|
||||||
|
if (soldCount.value == null) return undefined
|
||||||
|
return Math.max(0, soldCount.value - registeredCount.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Registered tickets only — what the "Scanned" tab shows.
|
||||||
|
const registeredTickets = computed(
|
||||||
|
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
function handleResult(qrText: string) {
|
function handleResult(qrText: string) {
|
||||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||||
|
|
@ -71,10 +97,16 @@ function fmtTime(iso: string) {
|
||||||
<ArrowLeft class="w-4 h-4" />
|
<ArrowLeft class="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<Button
|
||||||
<Ticket class="w-4 h-4" />
|
variant="ghost"
|
||||||
{{ scanned.length }} scanned this session
|
size="sm"
|
||||||
</div>
|
class="gap-1.5"
|
||||||
|
:disabled="statsLoading"
|
||||||
|
@click="refreshStats"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': statsLoading }" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
|
||||||
|
|
@ -82,133 +114,162 @@ function fmtTime(iso: string) {
|
||||||
{{ activity.title }}
|
{{ activity.title }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Scanner -->
|
<!-- Counts strip — backend-authoritative. Source: the
|
||||||
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
|
`events_list_event_tickets` RPC, refreshed after every scan.
|
||||||
<QRScanner @result="handleResult" @close="scannerOpen = false" />
|
Stays consistent across organizer devices unlike a
|
||||||
</div>
|
per-device localStorage count. -->
|
||||||
<div v-else class="flex justify-center my-6">
|
<div class="grid grid-cols-3 gap-2 mb-4">
|
||||||
<Button @click="scannerOpen = true" class="gap-1.5">
|
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
|
||||||
<Ticket class="w-4 h-4" />
|
<p class="text-2xl font-bold text-foreground">{{ registeredCount }}</p>
|
||||||
Resume scanning
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Scanned</p>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
|
||||||
|
<p class="text-2xl font-bold text-foreground">
|
||||||
<!-- Last-scan result. Sticks until the operator taps "Scan
|
{{ soldCount ?? '—' }}
|
||||||
next" — gives them time to verify the outcome and act on
|
|
||||||
it (let the attendee in, deny entry, etc.) before the
|
|
||||||
decode loop picks up the next QR. -->
|
|
||||||
<div
|
|
||||||
v-if="lastScan"
|
|
||||||
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
|
|
||||||
:class="{
|
|
||||||
'bg-emerald-500/10 border-emerald-500/40': lastScanVariant === 'success',
|
|
||||||
'bg-amber-500/10 border-amber-500/40': lastScanVariant === 'warning',
|
|
||||||
'bg-destructive/10 border-destructive/40': lastScanVariant === 'destructive',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<CheckCircle2
|
|
||||||
v-if="lastScanVariant === 'success'"
|
|
||||||
class="w-6 h-6 text-emerald-500 shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
<Clock
|
|
||||||
v-else-if="lastScanVariant === 'warning'"
|
|
||||||
class="w-6 h-6 text-amber-500 shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
<AlertCircle
|
|
||||||
v-else
|
|
||||||
class="w-6 h-6 text-destructive shrink-0 mt-0.5"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-base font-semibold text-foreground">
|
|
||||||
<template v-if="lastScan.status === 'ok'">
|
|
||||||
Registered
|
|
||||||
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
|
|
||||||
— {{ lastScan.ticket.name }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="lastScan.status === 'duplicate-session'">
|
|
||||||
Already scanned in this session
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ lastScan.message || 'Scan failed' }}
|
|
||||||
</template>
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs font-mono text-muted-foreground break-all mt-1">
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Sold</p>
|
||||||
{{ lastScan.ticketId }}
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
|
||||||
|
<p class="text-2xl font-bold text-foreground">
|
||||||
|
{{ remainingCount ?? '—' }}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Remaining</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- "Scan next" CTA — primary action when a result is pending,
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
so the operator's hand goes to the same place every time
|
<TabsList class="grid w-full grid-cols-2 mb-4">
|
||||||
(full-width button below the result). Disabled while the
|
<TabsTrigger value="scanner" class="gap-1.5">
|
||||||
RPC is still in-flight. -->
|
<ScanLine class="w-4 h-4" />
|
||||||
<Button
|
Scanner
|
||||||
v-if="isPaused"
|
</TabsTrigger>
|
||||||
class="w-full mt-3 gap-1.5"
|
<TabsTrigger value="list" class="gap-1.5">
|
||||||
size="lg"
|
<Ticket class="w-4 h-4" />
|
||||||
:disabled="isProcessing"
|
Scanned ({{ registeredCount }})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="scanner" class="mt-0">
|
||||||
|
<!-- Scanner -->
|
||||||
|
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
|
||||||
|
<QRScanner @result="handleResult" @close="scannerOpen = false" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex justify-center my-6">
|
||||||
|
<Button @click="scannerOpen = true" class="gap-1.5">
|
||||||
|
<Ticket class="w-4 h-4" />
|
||||||
|
Resume scanning
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sending indicator (popup handles success/error post-fact). -->
|
||||||
|
<p
|
||||||
|
v-if="isProcessing && !isPaused"
|
||||||
|
class="text-xs text-center text-muted-foreground mt-3"
|
||||||
|
>
|
||||||
|
Sending registration over Nostr…
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="list" class="mt-0">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h2 class="text-sm font-medium text-foreground">
|
||||||
|
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
|
||||||
|
<ul class="space-y-1.5 pr-3">
|
||||||
|
<li
|
||||||
|
v-for="record in registeredTickets"
|
||||||
|
:key="record.id"
|
||||||
|
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="record.registeredAt"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] font-mono px-1.5"
|
||||||
|
>
|
||||||
|
{{ fmtTime(record.registeredAt) }}
|
||||||
|
</Badge>
|
||||||
|
<span v-if="record.name" class="font-medium text-foreground">
|
||||||
|
{{ record.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||||
|
{{ record.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||||
|
No tickets scanned yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Full-screen result overlay. Tap anywhere to dismiss and
|
||||||
|
resume the decode loop. Replaces the inline banner so the
|
||||||
|
door operator can't miss the outcome — a busy entry meant
|
||||||
|
the small banner was easy to skim past. -->
|
||||||
|
<div
|
||||||
|
v-if="lastScan && isPaused"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-6 cursor-pointer"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-500/95': lastScanVariant === 'success',
|
||||||
|
'bg-amber-500/95': lastScanVariant === 'warning',
|
||||||
|
'bg-destructive/95': lastScanVariant === 'destructive',
|
||||||
|
}"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
@click="resume"
|
@click="resume"
|
||||||
>
|
>
|
||||||
<ScanLine class="w-4 h-4" />
|
<div class="text-center max-w-md">
|
||||||
<span v-if="isProcessing">Sending…</span>
|
<CheckCircle2
|
||||||
<span v-else>Scan next</span>
|
v-if="lastScanVariant === 'success'"
|
||||||
</Button>
|
class="w-32 h-32 mx-auto mb-4 text-white"
|
||||||
<p
|
/>
|
||||||
v-else-if="isProcessing"
|
<Clock
|
||||||
class="text-xs text-center text-muted-foreground mt-3"
|
v-else-if="lastScanVariant === 'warning'"
|
||||||
>
|
class="w-32 h-32 mx-auto mb-4 text-white"
|
||||||
Sending registration over Nostr…
|
/>
|
||||||
</p>
|
<AlertCircle
|
||||||
|
v-else
|
||||||
<Separator class="my-6" />
|
class="w-32 h-32 mx-auto mb-4 text-white"
|
||||||
|
/>
|
||||||
<!-- Scanned-this-session list. Persists to localStorage per
|
<p class="text-3xl sm:text-4xl font-bold text-white">
|
||||||
activity, mirroring the LNbits admin register page's
|
<template v-if="lastScan.status === 'ok'">
|
||||||
events_scanned_<eventId> pattern. -->
|
Registered
|
||||||
<div class="space-y-3">
|
</template>
|
||||||
<div class="flex items-center justify-between">
|
<template v-else-if="lastScan.status === 'duplicate-session'">
|
||||||
<h2 class="text-sm font-medium text-foreground">
|
Already scanned
|
||||||
Scanned ({{ scanned.length }})
|
</template>
|
||||||
</h2>
|
<template v-else>
|
||||||
<Button
|
Scan failed
|
||||||
v-if="scanned.length > 0"
|
</template>
|
||||||
variant="ghost"
|
</p>
|
||||||
size="sm"
|
<p
|
||||||
class="gap-1.5 text-xs"
|
v-if="lastScan.status === 'ok' && lastScan.ticket?.name"
|
||||||
@click="clearScanned"
|
class="text-xl text-white/90 mt-2 font-medium"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
{{ lastScan.ticket.name }}
|
||||||
Clear list
|
</p>
|
||||||
</Button>
|
<p
|
||||||
|
v-else-if="lastScan.status === 'error'"
|
||||||
|
class="text-base text-white/90 mt-2"
|
||||||
|
>
|
||||||
|
{{ lastScan.message }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-mono text-white/70 break-all mt-4 px-4">
|
||||||
|
{{ lastScan.ticketId }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/80 mt-8">
|
||||||
|
Tap anywhere to scan next
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea v-if="scanned.length > 0" class="h-72">
|
|
||||||
<ul class="space-y-1.5 pr-3">
|
|
||||||
<li
|
|
||||||
v-for="record in scanned"
|
|
||||||
:key="record.ticketId"
|
|
||||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
|
||||||
>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary" class="text-[10px] font-mono px-1.5">
|
|
||||||
{{ fmtTime(record.registeredAt) }}
|
|
||||||
</Badge>
|
|
||||||
<span v-if="record.name" class="font-medium text-foreground">
|
|
||||||
{{ record.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
|
||||||
{{ record.ticketId }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ScrollArea>
|
|
||||||
<p v-else class="text-sm text-muted-foreground text-center py-6">
|
|
||||||
No tickets scanned yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue