diff --git a/src/modules/activities/composables/useTicketScanner.ts b/src/modules/activities/composables/useTicketScanner.ts new file mode 100644 index 0000000..638b8bc --- /dev/null +++ b/src/modules/activities/composables/useTicketScanner.ts @@ -0,0 +1,113 @@ +import { ref, 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' + +export type ScanStatus = 'ok' | 'duplicate-session' | 'error' + +export interface ScanResult { + status: ScanStatus + ticketId: string + /** Backend response payload on OK. */ + ticket?: Record + /** Error string from the backend or local validation. */ + message?: string +} + +export interface ScanRecord { + ticketId: string + /** Holder display name from the backend, if any. */ + name?: string | null + registeredAt: string +} + +/** + * 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. + * + * Mirrors the LNbits admin Quasar register page's + * `events_scanned_` localStorage key with the + * `activities_scanned_` prefix. + */ +export function useTicketScanner(activityId: Ref) { + const transport = injectService( + SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, + ) + + const isProcessing = ref(false) + const lastScan = ref(null) + const scanned = useLocalStorage( + () => `activities_scanned_${activityId.value}`, + [], + ) + + function parseTicketId(qrText: string): string { + return qrText.startsWith('ticket://') + ? qrText.slice('ticket://'.length) + : qrText + } + + async function onDecode(qrText: string): Promise { + if (isProcessing.value) return + const ticketId = parseTicketId(qrText).trim() + if (!ticketId) return + + // Session-local de-dup. Distinct from the backend's "already + // registered" — this guards against the QR being held in front + // of the camera for multiple decode frames. + if (scanned.value.some(r => r.ticketId === ticketId)) { + lastScan.value = { status: 'duplicate-session', ticketId } + return + } + + isProcessing.value = true + try { + const ticket = await transport.call>( + 'events_ticket_register', + { + event_id: activityId.value, + ticket_id: ticketId, + }, + ) + const name = (ticket?.name as string | null | undefined) ?? null + scanned.value = [ + { ticketId, name, registeredAt: new Date().toISOString() }, + ...scanned.value, + ] + lastScan.value = { status: 'ok', ticketId, ticket } + } catch (e) { + // Backend RPC errors arrive as NostrRpcError with the + // string in `.message`: "Ticket not paid for", "Ticket + // already registered", "Ticket does not exist on this + // event", "You do not own this event", etc. + lastScan.value = { + status: 'error', + ticketId, + message: e instanceof Error ? e.message : String(e), + } + } finally { + isProcessing.value = false + } + } + + function clearScanned() { + scanned.value = [] + lastScan.value = null + } + + function dismissLastScan() { + lastScan.value = null + } + + return { + isProcessing, + lastScan, + scanned, + onDecode, + clearScanned, + dismissLastScan, + } +} diff --git a/src/modules/activities/index.ts b/src/modules/activities/index.ts index ef0b957..4d7bc9c 100644 --- a/src/modules/activities/index.ts +++ b/src/modules/activities/index.ts @@ -78,6 +78,15 @@ export const activitiesModule = createModulePlugin({ requiresAuth: true, }, }, + { + path: '/scan/:activityId', + name: 'scan-tickets', + component: () => import('./views/ScanTicketsPage.vue'), + meta: { + title: 'Scan Tickets', + requiresAuth: true, + }, + }, { path: '/events', name: 'events', diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index 84a0c64..fe9d322 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { - Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, + Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, } from 'lucide-vue-next' import { useActivityDetail } from '../composables/useActivityDetail' import BookmarkButton from '../components/BookmarkButton.vue' @@ -64,6 +64,10 @@ function openEditDialog() { activitiesStore.showCreateDialog = true } +function openScannerPage() { + router.push({ name: 'scan-tickets', params: { activityId } }) +} + const dateDisplay = computed(() => { if (!activity.value) return '' const a = activity.value @@ -157,6 +161,17 @@ function goToMyTickets() { Back
+