feat(activities): one row per attendee + render N QRs on multi-buy

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://<id>` QRs and PUTs /tickets/register/<id>. 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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-23 22:36:21 +02:00
commit 493a12a86b
8 changed files with 98 additions and 102 deletions

View file

@ -53,8 +53,8 @@ const {
handleOpenLightningWallet,
resetPaymentState,
cleanup,
ticketQRCode,
purchasedTicketId,
ticketQRCodes,
purchasedTicketIds,
showTicketQR
} = useTicketPurchase()
@ -543,35 +543,48 @@ onUnmounted(() => {
</div>
</div>
<!-- Ticket QR Code (After Successful Purchase) -->
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
<!-- Ticket QR Codes (After Successful Purchase) one QR per
row so each attendee can be scanned independently at the
door. Single-ticket purchases just render one. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">
{{ quantity > 1 ? `${quantity} tickets purchased!` : 'Ticket purchased!' }}
{{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }}
</h3>
<p class="text-sm text-muted-foreground">
<span v-if="quantity > 1">
{{ quantity }} tickets are now in your tickets area on a
single purchase row.
<span v-if="purchasedTicketIds.length > 1">
Each attendee scans their own QR at the door
independent of the others.
</span>
<span v-else>
Your ticket has been purchased and is now available in
your tickets area.
Your ticket is now in your tickets area.
</span>
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 w-full">
<div class="text-center space-y-3">
<div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" />
<div class="w-full space-y-3">
<div
v-for="(ticketId, index) in purchasedTicketIds"
:key="ticketId"
class="bg-muted/50 rounded-lg p-4"
>
<div class="text-center space-y-2">
<div v-if="purchasedTicketIds.length > 1" class="text-xs font-medium text-muted-foreground">
Ticket {{ index + 1 }} of {{ purchasedTicketIds.length }}
</div>
<div>
<p class="text-sm font-medium">
{{ quantity > 1 ? 'Purchase ID (covers all tickets)' : 'Ticket ID' }}
</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
<div class="flex justify-center">
<img
v-if="ticketQRCodes[ticketId]"
:src="ticketQRCodes[ticketId]"
:alt="`Ticket QR ${index + 1}`"
class="w-32 h-32 border rounded-lg bg-white p-1"
/>
<Ticket v-else class="w-12 h-12 text-green-600" />
</div>
<div class="bg-background border rounded px-2 py-1 max-w-full">
<p class="text-[10px] font-mono break-all">{{ ticketId }}</p>
</div>
</div>
</div>

View file

@ -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,

View file

@ -20,9 +20,16 @@ export function useTicketPurchase() {
const qrCode = ref<string | null>(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<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
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<string, string> = {}
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

View file

@ -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<string, GroupedTickets>()
@ -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,

View file

@ -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,
}
}

View file

@ -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[]
}
/**

View file

@ -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() {
<div
v-for="ticket in ownedTicketsForActivity.filter(t => t.paid)"
:key="ticket.id"
class="flex items-center gap-2 text-xs text-muted-foreground"
class="text-xs font-mono text-muted-foreground break-all"
>
<Badge
v-if="seatsOnRow(ticket) > 1"
variant="secondary"
class="shrink-0 text-[10px] font-mono px-1.5"
>
×{{ seatsOnRow(ticket) }}
</Badge>
<span class="font-mono break-all">{{ ticket.id }}</span>
{{ ticket.id }}
</div>
</div>
<Button variant="outline" size="sm" class="gap-1.5" @click="goToMyTickets">

View file

@ -17,11 +17,6 @@ const {
pendingTickets,
registeredTickets,
groupedTickets,
totalSeats,
paidSeats,
pendingSeats,
registeredSeats,
seatsOnRow,
isLoading,
error,
refresh
@ -163,10 +158,10 @@ onMounted(async () => {
<div v-else-if="tickets.length > 0">
<Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ totalSeats }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidSeats }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingSeats }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredSeats }})</TabsTrigger>
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList>
<!-- All Tickets Tab -->
@ -178,14 +173,11 @@ onMounted(async () => {
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<Badge variant="outline">
{{ group.paidCount + group.pendingCount }} ticket{{ (group.paidCount + group.pendingCount) !== 1 ? 's' : '' }}
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
</Badge>
</div>
<CardDescription>
{{ group.paidCount }} paid · {{ group.pendingCount }} pending · {{ group.registeredCount }} registered
<span v-if="group.tickets.length !== (group.paidCount + group.pendingCount)" class="block text-xs">
({{ group.tickets.length }} purchase{{ group.tickets.length !== 1 ? 's' : '' }})
</span>
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
@ -225,18 +217,9 @@ onMounted(async () => {
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets, group.eventId).id.slice(0, 8) }}
</span>
<Badge
v-if="seatsOnRow(getCurrentTicket(group.tickets, group.eventId)) > 1"
variant="secondary"
class="text-[10px] font-mono px-1.5"
>
×{{ seatsOnRow(getCurrentTicket(group.tickets, group.eventId)) }}
</Badge>
</div>
<Badge :variant="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).status === 'pending' ? 'secondary' : 'default'">
{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}
</Badge>