From b4baad0d825a80320e4572330032041afc6e8ff3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 24 May 2026 23:33:12 +0200 Subject: [PATCH] feat(activities): backend-truth counts + scanned list, tabs + popup result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../composables/useTicketScanner.ts | 82 ++++- .../activities/views/ScanTicketsPage.vue | 313 +++++++++++------- 2 files changed, 262 insertions(+), 133 deletions(-) diff --git a/src/modules/activities/composables/useTicketScanner.ts b/src/modules/activities/composables/useTicketScanner.ts index 50006a1..7053f5e 100644 --- a/src/modules/activities/composables/useTicketScanner.ts +++ b/src/modules/activities/composables/useTicketScanner.ts @@ -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_` localStorage key with the - * `activities_scanned_` 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) { const transport = injectService( @@ -50,6 +68,11 @@ export function useTicketScanner(activityId: Ref) { * just succeeded. */ const isPaused = ref(false) + /** Server-authoritative counts + per-ticket registered status. */ + const eventStats = ref(null) + const statsLoading = ref(false) + const statsError = ref(null) + /** Session-local dedup. Hidden from UI; only guards repeat decodes. */ const scanned = useLocalStorage( () => `activities_scanned_${activityId.value}`, [], @@ -61,6 +84,40 @@ export function useTicketScanner(activityId: Ref) { : qrText } + async function refreshStats(): Promise { + 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 { if (isProcessing.value || isPaused.value) return const ticketId = parseTicketId(qrText).trim() @@ -107,6 +164,9 @@ export function useTicketScanner(activityId: Ref) { // 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) { isPaused.value = false } + onMounted(() => { + void refreshStats() + }) + return { isProcessing, isPaused, lastScan, scanned, + eventStats, + statsLoading, + statsError, + refreshStats, onDecode, resume, clearScanned, diff --git a/src/modules/activities/views/ScanTicketsPage.vue b/src/modules/activities/views/ScanTicketsPage.vue index 67c51f5..a1b6648 100644 --- a/src/modules/activities/views/ScanTicketsPage.vue +++ b/src/modules/activities/views/ScanTicketsPage.vue @@ -1,12 +1,20 @@