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:
parent
ab171b4903
commit
c29f7e4d6b
8 changed files with 98 additions and 102 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue