feat(activities): ticket purchase + Nostr-driven inventory sync #71
5 changed files with 54 additions and 12 deletions
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>
commit
be7bcd393e
|
|
@ -255,10 +255,16 @@ onUnmounted(() => {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<DialogTitle class="flex items-center gap-2">
|
||||||
<CreditCard class="w-5 h-5" />
|
<CreditCard class="w-5 h-5" />
|
||||||
Purchase Ticket
|
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -540,9 +546,18 @@ onUnmounted(() => {
|
||||||
<!-- Ticket QR Code (After Successful Purchase) -->
|
<!-- Ticket QR Code (After Successful Purchase) -->
|
||||||
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
|
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
|
||||||
<div class="text-center space-y-2">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -552,7 +567,9 @@ onUnmounted(() => {
|
||||||
<Ticket class="w-12 h-12 text-green-600" />
|
<Ticket class="w-12 h-12 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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>
|
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,17 @@ function getTickets(activityId: string): ActivityTicket[] {
|
||||||
return ticketsByActivity.value.get(activityId) ?? []
|
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 {
|
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() {
|
export function useOwnedTickets() {
|
||||||
|
|
@ -116,6 +125,7 @@ export function useOwnedTickets() {
|
||||||
ownedActivityIds,
|
ownedActivityIds,
|
||||||
getTickets,
|
getTickets,
|
||||||
paidCount,
|
paidCount,
|
||||||
|
seatsOnRow,
|
||||||
refresh: fetchTickets,
|
refresh: fetchTickets,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|
|
||||||
|
|
@ -84,14 +84,18 @@ export function useUserTickets() {
|
||||||
const group = groups.get(eventKey)!
|
const group = groups.get(eventKey)!
|
||||||
group.tickets.push(ticket)
|
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) {
|
if (ticket.paid) {
|
||||||
group.paidCount++
|
group.paidCount += seats
|
||||||
} else {
|
} else {
|
||||||
group.pendingCount++
|
group.pendingCount += seats
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.registered) {
|
if (ticket.registered) {
|
||||||
group.registeredCount++
|
group.registeredCount += seats
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ export interface ActivityTicketExtra {
|
||||||
email_notification_sent: boolean
|
email_notification_sent: boolean
|
||||||
nostr_notification_sent: boolean
|
nostr_notification_sent: boolean
|
||||||
refunded: 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 {
|
export interface ActivityTicket {
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ function goBack() {
|
||||||
|
|
||||||
// --- Ticket purchase + owned-tickets surface ----------------------
|
// --- Ticket purchase + owned-tickets surface ----------------------
|
||||||
|
|
||||||
const { getTickets, paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
|
const { getTickets, paidCount, seatsOnRow, refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||||
|
|
||||||
const ownedTicketsForActivity = computed(() => getTickets(activityId))
|
const ownedTicketsForActivity = computed(() => getTickets(activityId))
|
||||||
const ownedPaidCount = computed(() => paidCount(activityId))
|
const ownedPaidCount = computed(() => paidCount(activityId))
|
||||||
|
|
@ -290,9 +290,16 @@ function goToMyTickets() {
|
||||||
<div
|
<div
|
||||||
v-for="ticket in ownedTicketsForActivity.filter(t => t.paid)"
|
v-for="ticket in ownedTicketsForActivity.filter(t => t.paid)"
|
||||||
:key="ticket.id"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" class="gap-1.5" @click="goToMyTickets">
|
<Button variant="outline" size="sm" class="gap-1.5" @click="goToMyTickets">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue