feat(events): handle free tickets in the purchase flow #131

Merged
padreug merged 1 commit from feat/free-tickets-client into dev 2026-06-20 09:58:39 +00:00
4 changed files with 81 additions and 42 deletions

View file

@ -70,6 +70,11 @@ function increaseQuantity() {
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value) const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// Free events (price 0): no invoice is minted the backend issues the
// tickets already-paid. Drop the payment-method selector and price line
// and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0)
async function copyInvoice() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { try {
@ -259,7 +264,10 @@ onUnmounted(() => {
<DialogDescription> <DialogDescription>
<span v-if="quantity > 1"> <span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> · {{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ formatEventPrice(totalPrice, event.currency) }} {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else-if="isFree">
Get a free ticket for <strong>{{ event.name }}</strong>
</span> </span>
<span v-else> <span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }} Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
@ -375,9 +383,9 @@ onUnmounted(() => {
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span> </span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span> <span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
</div> </div>
<PriceConversionPreview <PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency" v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
@ -396,7 +404,7 @@ onUnmounted(() => {
Lightning rather than collapsing into a single "Fiat" Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. --> keep the dialog uncluttered. -->
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2"> <div v-if="canChooseFiat && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div> <div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails. Both methods charge the same amount via different rails.
@ -437,6 +445,10 @@ onUnmounted(() => {
class="w-full" class="w-full"
> >
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="isFree">
<Ticket class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
</template>
<template v-else-if="selectedMethod?.rail === 'fiat'"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" /> <CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout Continue to {{ selectedMethod.label }} checkout

View file

@ -94,6 +94,38 @@ export function useTicketPurchase() {
* have to take it as an argument from the UI. */ * have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null) const currentEventId = ref<string | null>(null)
/**
* Advance to the ticket-QR success state for the given row ids. Shared
* by the Lightning-poll path (payment confirmed) and the free-ticket
* path (issued + paid server-side, no invoice) so both render one
* scannable QR per attendee identically.
*/
async function finalizePurchasedTickets(ids: string[]) {
// Ticket row(s) now exist — refresh the shared owned-tickets state so
// the feed/calendar My-tickets filter and owned badges reflect the
// purchase immediately (no reload). Runs in parallel with QR gen.
void refreshOwnedTickets()
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(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
}
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
eventId: string, eventId: string,
options: { quantity?: number } = {}, options: { quantity?: number } = {},
@ -125,13 +157,23 @@ export function useTicketPurchase() {
{ quantity: options.quantity }, { quantity: options.quantity },
) )
// Backend now returns either a Lightning invoice or a fiat // Free tickets (price 0 / 100%-off promo): the backend already
// checkout URL (post-events-v1.4.0). This composable only knows // issued + marked them paid, so there's no invoice to settle. Skip
// how to drive the Lightning path; fiat would need a separate // the QR / payment-poll and jump straight to the ticket-QR success
// redirect-to-provider flow that lives in PurchaseTicketDialog // state with the ids returned inline.
// (it has the user-visible payment-method selector). Reject the if (invoice.paid) {
// fiat response here so callers get a clear error instead of a paymentHash.value = invoice.paymentHash
// silent broken QR. await finalizePurchasedTickets(invoice.ticketIds ?? [])
return invoice
}
// Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent
// broken QR.
if (invoice.isFiat || !invoice.paymentRequest) { if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error( throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' + 'This event uses fiat checkout. Use the purchase dialog ' +
@ -185,40 +227,15 @@ export function useTicketPurchase() {
clearInterval(checkInterval) clearInterval(checkInterval)
} }
// Ticket row(s) now exist — refresh the shared owned-tickets
// state so the feed/calendar My-tickets filter and owned
// badges reflect the purchase immediately (no reload). Runs in
// parallel with QR generation below.
void refreshOwnedTickets()
// Multi-ticket purchases come back with `ticketIds` (N rows // Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include // sharing one invoice). Single-ticket purchases include
// `ticketId` only. Render one QR per row so each attendee // `ticketId` only.
// has their own scannable code at the door.
const ids = result.ticketIds && result.ticketIds.length > 0 const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds ? result.ticketIds
: result.ticketId : result.ticketId
? [result.ticketId] ? [result.ticketId]
: [] : []
await finalizePurchasedTickets(ids)
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(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error('Error checking payment status:', err)

View file

@ -105,6 +105,10 @@ export class TicketApiService {
fiatPaymentRequest: data.fiat_payment_request ?? undefined, fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined, fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat), isFiat: Boolean(data.is_fiat),
// Free tickets: backend returns paid=true with the row ids inline
// and no invoice to settle.
paid: Boolean(data.paid),
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} }
} }

View file

@ -81,10 +81,12 @@ export interface TicketPurchaseRequest {
} }
/** /**
* Server response from `POST /tickets/{event_id}`. Either Lightning * Server response from `POST /tickets/{event_id}`. One of three shapes:
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL * - Lightning: `paymentRequest` = bolt11 (`isFiat` false, `paid` false)
* the buyer follows to complete payment with `fiatProvider`). * - Fiat: `fiatPaymentRequest` = a URL the buyer follows with `fiatProvider`
* `isFiat` is the discriminator. * - Free: `paid` true with no `paymentRequest` tickets already issued +
* paid server-side (price 0 / 100%-off promo); `ticketIds` carries the
* scannable rows so the client skips the payment step entirely.
*/ */
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
@ -92,6 +94,10 @@ export interface TicketPurchaseInvoice {
fiatPaymentRequest?: string fiatPaymentRequest?: string
fiatProvider?: string fiatProvider?: string
isFiat: boolean isFiat: boolean
/** Free tickets: already issued + paid, no invoice to settle. */
paid?: boolean
/** Row ids returned inline for the free path (no poll needed). */
ticketIds?: string[]
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {