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
Showing only changes of commit afb57a3918 - Show all commits

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

Companion to aiolabs/events#31. Free events (price 0 / 100%-off promo)
now come back from POST /tickets/{event_id} as paid=true with the row
ids inline and no payment_request — the backend issued them
already-paid, no invoice to settle.

Previously the composable's `!paymentRequest` guard treated any
invoice-less response as fiat and threw "This event uses fiat
checkout", so free tickets were unbuyable.

- TicketPurchaseInvoice gains `paid` + `ticketIds`; TicketApiService
  maps them.
- purchaseTicketForEvent short-circuits on `invoice.paid`: skip the QR /
  payment-poll and go straight to the ticket-QR success state. The fiat
  error now only fires for an actual fiat (not-paid) response.
- The ticket-QR rendering (refresh owned tickets, one QR per row, toast)
  is extracted into a shared finalizePurchasedTickets() used by both the
  Lightning-poll path and the free path.
- PurchaseTicketDialog: for free events drop the payment-method selector
  and price line, show "Free", and label the CTA "Get ticket".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Padreug 2026-06-20 09:09:46 +02:00

View file

@ -70,6 +70,11 @@ function increaseQuantity() {
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() {
if (!paymentRequest.value) return
try {
@ -259,7 +264,10 @@ onUnmounted(() => {
<DialogDescription>
<span v-if="quantity > 1">
{{ 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 v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
@ -375,9 +383,9 @@ onUnmounted(() => {
</div>
<div class="flex justify-between">
<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 class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
<span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
</div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
@ -396,7 +404,7 @@ onUnmounted(() => {
Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to
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>
<p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails.
@ -437,6 +445,10 @@ onUnmounted(() => {
class="w-full"
>
<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'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout

View file

@ -94,6 +94,38 @@ export function useTicketPurchase() {
* have to take it as an argument from the UI. */
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(
eventId: string,
options: { quantity?: number } = {},
@ -125,13 +157,23 @@ export function useTicketPurchase() {
{ quantity: options.quantity },
)
// Backend now 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.
// Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip
// the QR / payment-poll and jump straight to the ticket-QR success
// state with the ids returned inline.
if (invoice.paid) {
paymentHash.value = invoice.paymentHash
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) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
@ -185,40 +227,15 @@ export function useTicketPurchase() {
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
// 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.
// `ticketId` only.
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(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
await finalizePurchasedTickets(ids)
}
} catch (err) {
console.error('Error checking payment status:', err)

View file

@ -105,6 +105,10 @@ export class TicketApiService {
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
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
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
* Server response from `POST /tickets/{event_id}`. One of three shapes:
* - Lightning: `paymentRequest` = bolt11 (`isFiat` false, `paid` false)
* - Fiat: `fiatPaymentRequest` = a URL the buyer follows with `fiatProvider`
* - 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 {
paymentHash: string
@ -92,6 +94,10 @@ export interface TicketPurchaseInvoice {
fiatPaymentRequest?: string
fiatProvider?: string
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 {