From c29f7e4d6b3c8657728ba0cf7ff534a38d8a62b7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 22:36:21 +0200 Subject: [PATCH] feat(activities): one row per attendee + render N QRs on multi-buy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to aiolabs/events PR #15's d087bf3 (N rows sharing one payment_hash). Now that the backend persists each attendee as a distinct scannable row, the webapp surfaces them properly: - TicketPaymentStatus carries `ticketIds: string[]` (every row), with `ticketId` kept for back-compat. checkPaymentStatus reads both fields off the polling response. - useTicketPurchase tracks `purchasedTicketIds` + `ticketQRCodes` (parallel map id → data url). After payment lands the composable generates one QR per row so each attendee has their own. - PurchaseTicketDialog success screen renders every QR + ticket id in a stack with "Ticket N of M" labels. Each can be shared with a different attendee for an independent door scan. Reverts the "seats via extra.quantity" workarounds that landed in the previous two commits — now that rows == tickets the counters go back to row-count semantics across MyTickets, ActivityCard badges, ActivityDetailPage owned-tickets, useUserTickets group tallies, and the dialog's success header. Door-scan compatibility: the existing LNbits register-page scanner (events ext static/js/register.js) already reads `ticket://` QRs and PUTs /tickets/register/. With N rows each having a unique uuid id, each attendee's QR maps to a distinct PUT — independent registration, all 3 friends can enter separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/PurchaseTicketDialog.vue | 55 ++++++++++++------- .../activities/composables/useOwnedTickets.ts | 15 ++--- .../composables/useTicketPurchase.ts | 44 +++++++++++++-- .../activities/composables/useUserTickets.ts | 29 +--------- .../activities/services/TicketApiService.ts | 1 + src/modules/activities/types/ticket.ts | 10 ++-- .../activities/views/ActivityDetailPage.vue | 13 +---- .../activities/views/MyTicketsPage.vue | 33 +++-------- 8 files changed, 98 insertions(+), 102 deletions(-) diff --git a/src/modules/activities/components/PurchaseTicketDialog.vue b/src/modules/activities/components/PurchaseTicketDialog.vue index c746d13..ec72c1f 100644 --- a/src/modules/activities/components/PurchaseTicketDialog.vue +++ b/src/modules/activities/components/PurchaseTicketDialog.vue @@ -53,8 +53,8 @@ const { handleOpenLightningWallet, resetPaymentState, cleanup, - ticketQRCode, - purchasedTicketId, + ticketQRCodes, + purchasedTicketIds, showTicketQR } = useTicketPurchase() @@ -543,35 +543,48 @@ onUnmounted(() => { - -
+ +

- {{ quantity > 1 ? `${quantity} tickets purchased!` : 'Ticket purchased!' }} + {{ purchasedTicketIds.length > 1 + ? `${purchasedTicketIds.length} tickets purchased!` + : 'Ticket purchased!' }}

- - {{ quantity }} tickets are now in your tickets area on a - single purchase row. + + Each attendee scans their own QR at the door — + independent of the others. - Your ticket has been purchased and is now available in - your tickets area. + Your ticket is now in your tickets area.

-
-
-
- -
-
-

- {{ quantity > 1 ? 'Purchase ID (covers all tickets)' : 'Ticket ID' }} -

-
-

{{ purchasedTicketId }}

+
+
+
+
+ Ticket {{ index + 1 }} of {{ purchasedTicketIds.length }} +
+
+ + +
+
+

{{ ticketId }}

diff --git a/src/modules/activities/composables/useOwnedTickets.ts b/src/modules/activities/composables/useOwnedTickets.ts index 82b4246..5fea1f1 100644 --- a/src/modules/activities/composables/useOwnedTickets.ts +++ b/src/modules/activities/composables/useOwnedTickets.ts @@ -74,17 +74,11 @@ function getTickets(activityId: string): ActivityTicket[] { return ticketsByActivity.value.get(activityId) ?? [] } -/** Seats represented by a single ticket row. One row carries N - * seats via extra.quantity; default 1 for legacy single-purchase rows. */ -function seatsOnRow(ticket: ActivityTicket): number { - return Math.max(1, ticket.extra?.quantity ?? 1) -} - -/** Total paid seats across all rows for one activity (sums extra.quantity). */ +/** Number of paid ticket rows for an activity. With the + * multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2), + * this matches the number of attendees / scannable QRs. */ function paidCount(activityId: string): number { - return getTickets(activityId) - .filter(t => t.paid) - .reduce((sum, t) => sum + seatsOnRow(t), 0) + return getTickets(activityId).filter(t => t.paid).length } export function useOwnedTickets() { @@ -125,7 +119,6 @@ export function useOwnedTickets() { ownedActivityIds, getTickets, paidCount, - seatsOnRow, refresh: fetchTickets, isLoading, error, diff --git a/src/modules/activities/composables/useTicketPurchase.ts b/src/modules/activities/composables/useTicketPurchase.ts index edeb6da..4d4dc45 100644 --- a/src/modules/activities/composables/useTicketPurchase.ts +++ b/src/modules/activities/composables/useTicketPurchase.ts @@ -20,9 +20,16 @@ export function useTicketPurchase() { const qrCode = ref(null) const isPaymentPending = ref(false) - // Ticket QR code state + // Ticket QR code state. After payment lands, `purchasedTicketIds` + // is populated with every row id created on the invoice (one for + // a single-ticket purchase, N for multi). `ticketQRCodes` is a + // parallel map id → QR data URL so the UI can render one QR per + // attendee. `purchasedTicketId` stays for back-compat with the + // single-id success path. const ticketQRCode = ref(null) + const ticketQRCodes = ref>({}) const purchasedTicketId = ref(null) + const purchasedTicketIds = ref([]) const showTicketQR = ref(false) // Computed properties @@ -94,7 +101,9 @@ export function useTicketPurchase() { paymentRequest.value = null qrCode.value = null ticketQRCode.value = null + ticketQRCodes.value = {} purchasedTicketId.value = null + purchasedTicketIds.value = [] showTicketQR.value = false currentEventId.value = eventId @@ -169,13 +178,34 @@ export function useTicketPurchase() { clearInterval(checkInterval) } - if (result.ticketId) { - purchasedTicketId.value = result.ticketId - await generateTicketQRCode(result.ticketId) + // Multi-ticket purchases come back with `ticketIds` (N rows + // sharing one invoice). Single-ticket purchases include + // `ticketId` only. Render one QR per row so each attendee + // has their own scannable code at the door. + const ids = result.ticketIds && result.ticketIds.length > 0 + ? result.ticketIds + : result.ticketId + ? [result.ticketId] + : [] + + if (ids.length > 0) { + purchasedTicketIds.value = ids + purchasedTicketId.value = ids[0] + const qrMap: Record = {} + for (const id of ids) { + const dataUrl = await generateTicketQRCode(id) + if (dataUrl) qrMap[id] = dataUrl + } + ticketQRCodes.value = qrMap + ticketQRCode.value = qrMap[ids[0]] ?? null showTicketQR.value = true } - toast.success('Ticket purchased successfully!') + toast.success( + ids.length > 1 + ? `${ids.length} tickets purchased!` + : 'Ticket purchased successfully!', + ) } } catch (err) { console.error('Error checking payment status:', err) @@ -197,7 +227,9 @@ export function useTicketPurchase() { qrCode.value = null isPaymentPending.value = false ticketQRCode.value = null + ticketQRCodes.value = {} purchasedTicketId.value = null + purchasedTicketIds.value = [] showTicketQR.value = false } @@ -225,7 +257,9 @@ export function useTicketPurchase() { isPaymentPending, isPayingWithWallet, ticketQRCode, + ticketQRCodes, purchasedTicketId, + purchasedTicketIds, showTicketQR, // Computed diff --git a/src/modules/activities/composables/useUserTickets.ts b/src/modules/activities/composables/useUserTickets.ts index c8571c4..ecdfb06 100644 --- a/src/modules/activities/composables/useUserTickets.ts +++ b/src/modules/activities/composables/useUserTickets.ts @@ -66,20 +66,6 @@ export function useUserTickets() { return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered) }) - // Seat counts (sum extra.quantity across rows) so the tab pills - // surface "what the buyer bought" rather than "how many purchase - // rows are in the database". A multi-ticket purchase is one row - // with extra.quantity = N seats. - function seatsOnRow(t: ActivityTicket): number { - return Math.max(1, t.extra?.quantity ?? 1) - } - function sumSeats(arr: ActivityTicket[]): number { - return arr.reduce((total, t) => total + seatsOnRow(t), 0) - } - const totalSeats = computed(() => sumSeats(sortedTickets.value)) - const paidSeats = computed(() => sumSeats(paidTickets.value)) - const pendingSeats = computed(() => sumSeats(pendingTickets.value)) - const registeredSeats = computed(() => sumSeats(registeredTickets.value)) const groupedTickets = computed(() => { const groups = new Map() @@ -99,18 +85,14 @@ export function useUserTickets() { const group = groups.get(eventKey)! group.tickets.push(ticket) - // A ticket row represents N seats via extra.quantity (default 1 - // for legacy single-purchase rows). Counters sum seats, not - // rows, so the user sees the actual ticket count they bought. - const seats = Math.max(1, ticket.extra?.quantity ?? 1) if (ticket.paid) { - group.paidCount += seats + group.paidCount++ } else { - group.pendingCount += seats + group.pendingCount++ } if (ticket.registered) { - group.registeredCount += seats + group.registeredCount++ } }) @@ -134,11 +116,6 @@ export function useUserTickets() { registeredTickets, unregisteredTickets, groupedTickets, - totalSeats, - paidSeats, - pendingSeats, - registeredSeats, - seatsOnRow, isLoading, error, refresh: loadTickets, diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index 6086d51..f127d5e 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -123,6 +123,7 @@ export class TicketApiService { return { paid: data.paid === true, ticketId: data.ticket_id, + ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined, } } diff --git a/src/modules/activities/types/ticket.ts b/src/modules/activities/types/ticket.ts index 164e370..a80783c 100644 --- a/src/modules/activities/types/ticket.ts +++ b/src/modules/activities/types/ticket.ts @@ -37,10 +37,6 @@ export interface ActivityTicketExtra { email_notification_sent: boolean nostr_notification_sent: boolean refunded: boolean - /** Number of seats represented by this ticket row (1..10). One - * row per purchase keeps the schema unchanged; callers must - * multiply by this when surfacing seat counts. */ - quantity?: number } export interface ActivityTicket { @@ -100,7 +96,13 @@ export interface TicketPurchaseInvoice { export interface TicketPaymentStatus { paid: boolean + /** First ticket id created on this invoice. Back-compat with + * single-ticket purchases — equals the payment_hash. */ ticketId?: string + /** Every row created on this invoice — one for single-ticket + * purchases, N for multi-ticket. Each row is independently + * scannable at the door. */ + ticketIds?: string[] } /** diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index e026e2f..c347363 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -100,7 +100,7 @@ function goBack() { // --- Ticket purchase + owned-tickets surface ---------------------- -const { getTickets, paidCount, seatsOnRow, refresh: refreshOwnedTickets } = useOwnedTickets() +const { getTickets, paidCount, refresh: refreshOwnedTickets } = useOwnedTickets() const ownedTicketsForActivity = computed(() => getTickets(activityId)) const ownedPaidCount = computed(() => paidCount(activityId)) @@ -290,16 +290,9 @@ function goToMyTickets() {
- - ×{{ seatsOnRow(ticket) }} - - {{ ticket.id }} + {{ ticket.id }}