fix(activities): count seats by extra.quantity across all UI surfaces

Earlier commit landed the backend storing N seats on one row via
extra.quantity (one invoice, one payment, one ticket row), but the
UI kept counting rows instead of seats. A 5-ticket purchase
showed:

  Dialog header: "Purchase a ticket for X for 100 sats"  ← lied
  Success modal: "Ticket purchased!" / one ticket ID    ← lied
  My Tickets / badges: "1 paid ticket"                  ← lied

even though the buyer correctly paid 500 sats and 5 seats were
sold (DB verified: extra.quantity=5, sats_paid=500, event.sold
incremented by 5). The bolt11 invoice amount is cryptographic so
the wallet charge was always right — only the labels were wrong.

Fixes:

- ActivityTicketExtra grows `quantity?: number` (the field already
  on the wire from the backend; just adding it to the type).
- useOwnedTickets exposes `seatsOnRow(ticket)` and `paidCount`
  sums seats (extra.quantity) across rows instead of counting
  rows. ActivityCard's "You have N tickets" badge now reflects
  actual seat ownership.
- useUserTickets.groupedTickets sums seats into paidCount /
  pendingCount / registeredCount so MyTicketsPage groups read
  correctly.
- ActivityDetailPage owned-tickets section adds a `×N` chip on
  rows that represent multiple seats so the buyer can see which
  row covers how many.
- PurchaseTicketDialog header + DialogDescription reflect the
  selected quantity ("Purchase 5 tickets" / "5 tickets for X · 500
  sats"). The success modal switches to "5 tickets purchased!" and
  re-labels the ticket id "Purchase ID (covers all tickets)" so
  the buyer doesn't expect 5 separate ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-23 22:20:51 +02:00 committed by padreug
commit 9a14eaa401
5 changed files with 54 additions and 12 deletions

View file

@ -255,10 +255,16 @@ onUnmounted(() => {
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" />
Purchase Ticket
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle>
<DialogDescription>
<span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
</span>
</DialogDescription>
</DialogHeader>
@ -540,9 +546,18 @@ onUnmounted(() => {
<!-- Ticket QR Code (After Successful Purchase) -->
<div v-else-if="showTicketQR && ticketQRCode" 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">Ticket Purchased Successfully!</h3>
<h3 class="text-lg font-semibold text-green-600">
{{ quantity > 1 ? `${quantity} tickets purchased!` : 'Ticket purchased!' }}
</h3>
<p class="text-sm text-muted-foreground">
Your ticket has been purchased and is now available in your tickets area.
<span v-if="quantity > 1">
{{ quantity }} tickets are now in your tickets area on a
single purchase row.
</span>
<span v-else>
Your ticket has been purchased and is now available in
your tickets area.
</span>
</p>
</div>
@ -552,7 +567,9 @@ onUnmounted(() => {
<Ticket class="w-12 h-12 text-green-600" />
</div>
<div>
<p class="text-sm font-medium">Ticket ID</p>
<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>

View file

@ -74,8 +74,17 @@ 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). */
function paidCount(activityId: string): number {
return getTickets(activityId).filter(t => t.paid).length
return getTickets(activityId)
.filter(t => t.paid)
.reduce((sum, t) => sum + seatsOnRow(t), 0)
}
export function useOwnedTickets() {
@ -116,6 +125,7 @@ export function useOwnedTickets() {
ownedActivityIds,
getTickets,
paidCount,
seatsOnRow,
refresh: fetchTickets,
isLoading,
error,

View file

@ -84,14 +84,18 @@ 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++
group.paidCount += seats
} else {
group.pendingCount++
group.pendingCount += seats
}
if (ticket.registered) {
group.registeredCount++
group.registeredCount += seats
}
})

View file

@ -37,6 +37,10 @@ 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 {

View file

@ -100,7 +100,7 @@ function goBack() {
// --- Ticket purchase + owned-tickets surface ----------------------
const { getTickets, paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
const { getTickets, paidCount, seatsOnRow, refresh: refreshOwnedTickets } = useOwnedTickets()
const ownedTicketsForActivity = computed(() => getTickets(activityId))
const ownedPaidCount = computed(() => paidCount(activityId))
@ -290,9 +290,16 @@ function goToMyTickets() {
<div
v-for="ticket in ownedTicketsForActivity.filter(t => t.paid)"
:key="ticket.id"
class="text-xs font-mono text-muted-foreground break-all"
class="flex items-center gap-2 text-xs text-muted-foreground"
>
{{ ticket.id }}
<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>
</div>
</div>
<Button variant="outline" size="sm" class="gap-1.5" @click="goToMyTickets">