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:
parent
75306eaae8
commit
40edba8a8d
5 changed files with 54 additions and 12 deletions
|
|
@ -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>
|
||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue