From 68c8ee76d823db8238d51c0d3a3af44f44f5b7c4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 4 Jun 2026 23:40:02 +0200 Subject: [PATCH] feat(activities): manual ticket registration from the roster tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Scanned" tab becomes "Tickets" and now lists the full event roster (sold tickets), not just the registered subset. Unregistered rows lead the list with a Register button so the host can manually mark someone present without a QR scan — e.g. lost phone, known in person, or alternate proof of identity. useTicketScanner gains registerManually(ticketId), which calls the same PUT /tickets/register/{id} the scanner uses (so it inherits the event-ownership gate and the unpaid/already-registered backend checks), then refreshes stats. It skips the scanner pause + full- screen banner since the operator initiated the action from the list, and mirrors the session-local dedup so a subsequent QR scan on the same ticket reports "Already scanned" instead of a duplicate register round-trip. The header now reads "registered / total · N to go" so the host sees roster progress at a glance; failures from the manual register surface as a sonner toast and the row reverts. --- .../events/composables/useTicketScanner.ts | 33 +++++++ src/modules/events/views/ScanTicketsPage.vue | 90 +++++++++++++++---- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/src/modules/events/composables/useTicketScanner.ts b/src/modules/events/composables/useTicketScanner.ts index e8afc09..1afe819 100644 --- a/src/modules/events/composables/useTicketScanner.ts +++ b/src/modules/events/composables/useTicketScanner.ts @@ -188,6 +188,38 @@ export function useTicketScanner(eventId: Ref) { isPaused.value = false } + /** + * Mark a ticket as registered without going through the camera — + * used when the host knows the attendee in person or accepts an + * alternate proof of identity. Same backend endpoint as a scan + * (so it also gates on event ownership and rejects unpaid / + * already-registered tickets), but skips the scanner pause + + * full-screen banner since the operator initiated the action + * from the roster directly. Refreshes stats on success. + */ + async function registerManually( + ticketId: string, + ): Promise<{ ok: boolean; error?: string }> { + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) return { ok: false, error: 'No wallet admin key available' } + try { + await ticketApi.registerTicket(ticketId, adminKey) + // Mirror the session-local dedup the scan path uses so a + // subsequent QR scan of the same ticket reports "Already + // scanned" instead of round-tripping a duplicate register. + if (!scanned.value.some(r => r.ticketId === ticketId)) { + scanned.value = [ + { ticketId, name: null, registeredAt: new Date().toISOString() }, + ...scanned.value, + ] + } + await refreshStats() + return { ok: true } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } + } + function clearScanned() { scanned.value = [] lastScan.value = null @@ -210,5 +242,6 @@ export function useTicketScanner(eventId: Ref) { onDecode, resume, clearScanned, + registerManually, } } diff --git a/src/modules/events/views/ScanTicketsPage.vue b/src/modules/events/views/ScanTicketsPage.vue index 092ea3e..9d4aa2ce 100644 --- a/src/modules/events/views/ScanTicketsPage.vue +++ b/src/modules/events/views/ScanTicketsPage.vue @@ -1,6 +1,7 @@