Compare commits
2 commits
36791c8121
...
eb9ae54215
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9ae54215 | |||
| afb57a3918 |
4 changed files with 81 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue