feat(activities): backend-truth counts + scanned list, tabs + popup result on Scan Tickets #76

Merged
padreug merged 1 commit from ticket-scanner-counts-rpc into dev 2026-05-24 21:34:22 +00:00
2 changed files with 254 additions and 125 deletions

View file

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

View file

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