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:
Padreug 2026-05-24 23:33:12 +02:00
commit b4baad0d82
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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrTransportService } from '@/modules/base/services/NostrTransportService'
@ -21,16 +21,34 @@ export interface ScanRecord {
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
* useQRScanner upstream), the QR decode, the
* `events_ticket_register` RPC call, and a session-local scanned
* list persisted to localStorage so a page refresh doesn't ask the
* organizer to rescan tickets they already counted.
* `events_ticket_register` RPC call, an authoritative event roster
* fetched from `events_list_event_tickets`, and a session-local
* dedup cache.
*
* Mirrors the LNbits admin Quasar register page's
* `events_scanned_<eventId>` localStorage key with the
* `activities_scanned_<id>` prefix.
* Counts + the displayed scanned list come from the backend so the
* UI agrees with reality even when a second organizer is scanning
* 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>) {
const transport = injectService<NostrTransportService>(
@ -50,6 +68,11 @@ export function useTicketScanner(activityId: Ref<string>) {
* just succeeded.
*/
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[]>(
() => `activities_scanned_${activityId.value}`,
[],
@ -61,6 +84,40 @@ export function useTicketScanner(activityId: Ref<string>) {
: 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> {
if (isProcessing.value || isPaused.value) return
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,
// etc.) before the next QR comes into frame.
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
}
onMounted(() => {
void refreshStats()
})
return {
isProcessing,
isPaused,
lastScan,
scanned,
eventStats,
statsLoading,
statsError,
refreshStats,
onDecode,
resume,
clearScanned,

View file

@ -1,12 +1,20 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
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 { useTicketScanner } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail'
@ -21,13 +29,15 @@ const {
isProcessing,
isPaused,
lastScan,
scanned,
eventStats,
statsLoading,
refreshStats,
onDecode,
resume,
clearScanned,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner')
const lastScanVariant = computed(() => {
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) {
// Don't pause the scanner useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same
@ -71,10 +97,16 @@ function fmtTime(iso: string) {
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4" />
{{ scanned.length }} scanned this session
</div>
<Button
variant="ghost"
size="sm"
class="gap-1.5"
:disabled="statsLoading"
@click="refreshStats"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': statsLoading }" />
Refresh
</Button>
</div>
<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 }}
</p>
<!-- 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>
<!-- Last-scan result. Sticks until the operator taps "Scan
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>
<!-- Counts strip backend-authoritative. Source: the
`events_list_event_tickets` RPC, refreshed after every scan.
Stays consistent across organizer devices unlike a
per-device localStorage count. -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">{{ registeredCount }}</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Scanned</p>
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">
{{ soldCount ?? '—' }}
</p>
<p class="text-xs font-mono text-muted-foreground break-all mt-1">
{{ lastScan.ticketId }}
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Sold</p>
</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 class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Remaining</p>
</div>
</div>
<!-- "Scan next" CTA primary action when a result is pending,
so the operator's hand goes to the same place every time
(full-width button below the result). Disabled while the
RPC is still in-flight. -->
<Button
v-if="isPaused"
class="w-full mt-3 gap-1.5"
size="lg"
:disabled="isProcessing"
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="scanner" class="gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</TabsTrigger>
<TabsTrigger value="list" class="gap-1.5">
<Ticket class="w-4 h-4" />
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"
>
<ScanLine class="w-4 h-4" />
<span v-if="isProcessing">Sending</span>
<span v-else>Scan next</span>
</Button>
<p
v-else-if="isProcessing"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr
</p>
<Separator class="my-6" />
<!-- Scanned-this-session list. Persists to localStorage per
activity, mirroring the LNbits admin register page's
events_scanned_<eventId> pattern. -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-foreground">
Scanned ({{ scanned.length }})
</h2>
<Button
v-if="scanned.length > 0"
variant="ghost"
size="sm"
class="gap-1.5 text-xs"
@click="clearScanned"
<div class="text-center max-w-md">
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<AlertCircle
v-else
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<p class="text-3xl sm:text-4xl font-bold text-white">
<template v-if="lastScan.status === 'ok'">
Registered
</template>
<template v-else-if="lastScan.status === 'duplicate-session'">
Already scanned
</template>
<template v-else>
Scan failed
</template>
</p>
<p
v-if="lastScan.status === 'ok' && lastScan.ticket?.name"
class="text-xl text-white/90 mt-2 font-medium"
>
<Trash2 class="w-3.5 h-3.5" />
Clear list
</Button>
{{ lastScan.ticket.name }}
</p>
<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>
<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>
</template>