feat(events): guest checkout with email delivery, Stripe return flow, public ticket page #144

Merged
padreug merged 7 commits from feat/events-guest-checkout into dev 2026-09-08 10:25:57 +00:00
6 changed files with 495 additions and 136 deletions
Showing only changes of commit 891b5072f8 - Show all commits

feat(events): guest checkout in the purchase dialog with delivery email

Buyers no longer need an account to buy a paid ticket. The dialog gains a
"Your details" block (name for guests, email + confirm-email for everyone,
prefilled from the account email) as a vee-validate/zod form, and builds
its rail buttons from the event's published payment methods so an
organizer can run card-only events.

- Fiat: same-tab redirect to the provider right after the POST, with the
  pending checkout (ticket ids, email) remembered in localStorage for the
  return page; the two-step "open in new tab" panel is gone.
- Lightning: guests get the bolt11 QR + "Open in wallet" and settle via
  the existing poll; the LNbits-wallet button stays login-only.
- Free tickets remain login-gated (events#29).
- Guest success state renders the ticket QRs inline with the "emailed
  to" note, since guests have no My Tickets.

i18n keys added under events.purchase / checkout / ticket (en, es, fr).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Padreug 2026-09-06 19:35:45 +02:00

View file

@ -129,6 +129,51 @@ const messages: LocaleMessages = {
pending: 'Pending',
registered: 'Registered',
},
purchase: {
card: 'Card',
continueTo: 'Continue to {provider} checkout',
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.",
noCheckoutUrl: 'Fiat provider did not return a checkout URL.',
fiatFailed: 'Card checkout failed',
loginRequiredTitle: 'Login required',
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
contact: {
title: 'Your details',
name: 'Name',
namePlaceholder: 'Name on the ticket',
nameRequired: 'Name is required',
email: 'Email',
emailHint: 'Your ticket will be sent to this address.',
emailInvalid: 'Enter a valid email address',
confirmEmail: 'Confirm email',
emailMismatch: 'Email addresses do not match',
},
},
checkout: {
title: 'Confirming your payment…',
paidTitle: 'Payment received',
processing: 'Waiting for the payment to settle. This usually takes a few seconds.',
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.',
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.',
emailedTo: 'We emailed your ticket to {email}.',
cancelled: 'Payment cancelled — no seats were reserved.',
viewTicket: 'Open ticket page',
viewMyTickets: 'View My Tickets',
ticketN: 'Ticket {n} of {total}',
close: 'Close',
},
ticket: {
title: 'Your ticket',
loading: 'Loading ticket…',
notFound: 'Ticket not found',
event: 'Event',
holder: 'Name',
paid: 'Paid',
pending: 'Payment pending',
registered: 'Checked in',
showAtDoor: 'Show this QR at the door.',
backToEvent: 'Back to event',
},
nav: {
feed: 'Home',
calendar: 'Calendar',

View file

@ -129,6 +129,51 @@ const messages: LocaleMessages = {
pending: 'Pendiente',
registered: 'Registrado',
},
purchase: {
card: 'Tarjeta',
continueTo: 'Continuar al pago con {provider}',
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.',
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.',
fiatFailed: 'El pago con tarjeta falló',
loginRequiredTitle: 'Inicio de sesión requerido',
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
contact: {
title: 'Tus datos',
name: 'Nombre',
namePlaceholder: 'Nombre en el boleto',
nameRequired: 'El nombre es obligatorio',
email: 'Correo electrónico',
emailHint: 'Tu boleto se enviará a esta dirección.',
emailInvalid: 'Introduce un correo electrónico válido',
confirmEmail: 'Confirmar correo',
emailMismatch: 'Los correos no coinciden',
},
},
checkout: {
title: 'Confirmando tu pago…',
paidTitle: 'Pago recibido',
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.',
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.',
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.',
emailedTo: 'Enviamos tu boleto a {email}.',
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
viewTicket: 'Abrir página del boleto',
viewMyTickets: 'Ver mis boletos',
ticketN: 'Boleto {n} de {total}',
close: 'Cerrar',
},
ticket: {
title: 'Tu boleto',
loading: 'Cargando boleto…',
notFound: 'Boleto no encontrado',
event: 'Evento',
holder: 'Nombre',
paid: 'Pagado',
pending: 'Pago pendiente',
registered: 'Registrado',
showAtDoor: 'Muestra este QR en la entrada.',
backToEvent: 'Volver al evento',
},
nav: {
feed: 'Inicio',
calendar: 'Calendario',

View file

@ -129,6 +129,51 @@ const messages: LocaleMessages = {
pending: 'En attente',
registered: 'Enregistré',
},
purchase: {
card: 'Carte',
continueTo: 'Continuer vers le paiement {provider}',
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.',
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
fiatFailed: 'Le paiement par carte a échoué',
loginRequiredTitle: 'Connexion requise',
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
contact: {
title: 'Vos coordonnées',
name: 'Nom',
namePlaceholder: 'Nom sur le billet',
nameRequired: 'Le nom est obligatoire',
email: 'E-mail',
emailHint: 'Votre billet sera envoyé à cette adresse.',
emailInvalid: 'Saisissez une adresse e-mail valide',
confirmEmail: "Confirmer l'e-mail",
emailMismatch: 'Les adresses e-mail ne correspondent pas',
},
},
checkout: {
title: 'Confirmation de votre paiement…',
paidTitle: 'Paiement reçu',
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.',
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.',
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.',
emailedTo: 'Nous avons envoyé votre billet à {email}.',
cancelled: 'Paiement annulé — aucune place réservée.',
viewTicket: 'Ouvrir la page du billet',
viewMyTickets: 'Voir mes billets',
ticketN: 'Billet {n} sur {total}',
close: 'Fermer',
},
ticket: {
title: 'Votre billet',
loading: 'Chargement du billet…',
notFound: 'Billet introuvable',
event: 'Événement',
holder: 'Nom',
paid: 'Payé',
pending: 'Paiement en attente',
registered: 'Enregistré',
showAtDoor: "Présentez ce QR à l'entrée.",
backToEvent: "Retour à l'événement",
},
nav: {
feed: 'Accueil',
calendar: 'Calendrier',

View file

@ -104,6 +104,51 @@ export interface LocaleMessages {
pending: string
registered: string
}
purchase: {
card: string
continueTo: string
fiatRedirectHint: string
noCheckoutUrl: string
fiatFailed: string
loginRequiredTitle: string
loginRequiredFree: string
contact: {
title: string
name: string
namePlaceholder: string
nameRequired: string
email: string
emailHint: string
emailInvalid: string
confirmEmail: string
emailMismatch: string
}
}
checkout: {
title: string
paidTitle: string
processing: string
stillProcessing: string
stillProcessingNoEmail: string
emailedTo: string
cancelled: string
viewTicket: string
viewMyTickets: string
ticketN: string
close: string
}
ticket: {
title: string
loading: string
notFound: string
event: string
holder: string
paid: string
pending: string
registered: string
showAtDoor: string
backToEvent: string
}
nav: {
feed: string
calendar: string

View file

@ -1,13 +1,26 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
@ -15,6 +28,9 @@ import PaymentMethodSelector, {
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
import { effectivePaymentMethods } from '../lib/paymentMethods'
import { frontendRoot } from '../lib/frontendRoot'
import { PENDING_CHECKOUT_KEY, type PendingCheckout } from '../lib/pendingCheckout'
interface Props {
event: {
@ -25,6 +41,9 @@ interface Props {
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
/** Rails the organizer enabled (`tickets_payment_methods`). Absent
* on legacy events — falls back to the allow_fiat rule. */
payment_methods?: string[]
}
isOpen: boolean
}
@ -35,6 +54,7 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const { isAuthenticated, userDisplay, currentUser } = useAuth()
const {
@ -45,7 +65,6 @@ const {
qrCode,
isPaymentPending,
isPayingWithWallet,
canPurchase,
userWallets,
hasWalletWithBalance,
purchaseTicketForEvent,
@ -54,6 +73,7 @@ const {
resetPaymentState,
cleanup,
purchasedTicketIds,
ticketQRCodes,
showTicketQR
} = useTicketPurchase()
@ -75,6 +95,11 @@ const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0)
// Guests may buy paid tickets (name + email identity); free claims stay
// login-gated so an anonymous script can't drain a free event's
// capacity (events#29).
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
async function copyInvoice() {
if (!paymentRequest.value) return
try {
@ -87,16 +112,66 @@ async function copyInvoice() {
}
}
// --- Contact (delivery email) form ---------------------------------
// Every ticket carries an email so the extension can deliver it (and
// so Stripe's checkout is prefilled + locked to the same address).
// Guests additionally need a name — that pair is their identity on
// the backend. Confirm-email guards against typos on the one field a
// guest cannot fix afterwards.
const contactSchema = computed(() =>
toTypedSchema(
z
.object({
name: isAuthenticated.value
? z.string().optional().default('')
: z.string().trim().min(1, t('events.purchase.contact.nameRequired')).max(100),
email: z.string().trim().email(t('events.purchase.contact.emailInvalid')),
confirmEmail: z.string().trim(),
})
.superRefine((v, ctx) => {
if (v.email.toLowerCase() !== v.confirmEmail.toLowerCase()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmEmail'],
message: t('events.purchase.contact.emailMismatch'),
})
}
}),
),
)
const form = useForm({
validationSchema: contactSchema,
initialValues: { name: '', email: '', confirmEmail: '' },
})
// The dialog stays mounted across opens — reseed on every open so a
// login/logout between purchases is reflected.
watch(
() => props.isOpen,
(open) => {
if (!open) return
form.resetForm({
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
})
},
{ immediate: true },
)
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
const enabledRails = computed(() =>
effectivePaymentMethods({
allowFiat: props.event.allow_fiat,
paymentMethods: props.event.payment_methods,
}),
)
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats',
)
@ -124,125 +199,146 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
}
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = {
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
icon: Zap,
available: true,
badge:
!isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
}
if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id)
return {
id: `fiat:${id}`,
rail: 'fiat',
provider: id,
label: meta.label,
icon: iconFor(meta.icon),
available: true,
}
})
return [lightning, ...fiatRails]
}
// Degenerate fallback — allow_fiat is on but the buyer's session
// can't enumerate the organizer's providers. Show a generic Card
// button and let the backend pick a default at request time.
return [
lightning,
{
id: 'fiat',
rail: 'fiat',
label: 'Card',
icon: CreditCard,
const entries: PaymentMethodEntry[] = []
if (enabledRails.value.includes('lightning')) {
entries.push({
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
icon: Zap,
available: true,
},
]
badge:
!isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
})
}
if (canChooseFiat.value) {
if (providers.value.length > 0) {
for (const id of providers.value) {
const meta = providerMeta(id)
entries.push({
id: `fiat:${id}`,
rail: 'fiat',
provider: id,
label: meta.label,
icon: iconFor(meta.icon),
available: true,
})
}
} else {
// Guests (and sessions without provider info) can't enumerate the
// organizer's providers. Show a generic Card button and let the
// backend pick the organizer's default at request time.
entries.push({
id: 'fiat',
rail: 'fiat',
label: t('events.purchase.card'),
icon: CreditCard,
available: true,
})
}
}
return entries
})
// Default rail: guests lean to card when it's on offer (no wallet to
// pay a bolt11 with); everyone else takes the first enabled rail.
watch(
[paymentMethods, () => props.isOpen, isAuthenticated],
([methods, open, authed]) => {
if (!open || methods.length === 0) return
const current = methods.find((m) => m.id === selectedMethodId.value)
if (current) return
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined
selectedMethodId.value = (preferred ?? methods[0]).id
},
{ immediate: true },
)
const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
)
async function handlePurchase() {
if (!canPurchase.value) return
fiatError.value = null
const onSubmit = form.handleSubmit(async (contact) => {
await handlePurchase(contact)
})
async function handlePurchase(contact: { name?: string; email: string }) {
fiatError.value = null
const method = selectedMethod.value
if (!method) return
// Lightning path: the composable just creates the invoice + starts
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
const isGuest = !isAuthenticated.value
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
// Lightning path: the composable creates the invoice + starts polling.
// The buyer pays from their LNbits wallet or an external one on the
// invoice screen (restaurant pattern), so no auto-pay here.
if (method.rail === 'lightning') {
try {
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
await purchaseTicketForEvent(props.event.id, {
quantity: quantity.value,
contact: guestContact,
email: contact.email,
})
} catch (err) {
console.error('Error purchasing ticket:', err)
}
return
}
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// API directly with the chosen provider, then redirect the buyer to
// the provider's checkout URL. Payment confirmation happens via
// webhook on the backend and shows up next time the buyer reloads
// MyTickets.
// Fiat path: POST with the chosen provider, remember what we're
// waiting for, then hand the tab to the provider's checkout. Stripe
// brings the buyer back to the event page with ?checkout=success&
// tickets=… (or ?checkout=cancelled) — see EventDetailPage.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const userId = currentUser.value?.id
if (!userId) {
fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
const invoice = await ticketApi.requestTicket(props.event.id, {
userId: isGuest ? undefined : currentUser.value?.id,
name: guestContact?.name,
email: contact.email,
accessToken,
{
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
)
frontendUrl: frontendRoot(),
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
})
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
fiatError.value = t('events.purchase.noCheckoutUrl')
isFiatPending.value = false
return
}
fiatRedirectUrl.value = invoice.fiatPaymentRequest
fiatProviderLabel.value = invoice.fiatProvider
? providerMeta(invoice.fiatProvider).label
: method.label
const pending: PendingCheckout = {
eventId: props.event.id,
paymentHash: invoice.paymentHash,
ticketIds: invoice.ticketIds ?? [],
email: contact.email,
ts: Date.now(),
}
try {
localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(pending))
} catch {
// Storage unavailable (private mode) — the return URL still
// carries the ticket ids, so the flow survives without it.
}
window.location.assign(invoice.fiatPaymentRequest)
// Keep the spinner up until the navigation lands.
} catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
isFiatPending.value = false
}
}
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() {
emit('update:isOpen', false)
resetPaymentState()
selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
isFiatPending.value = false
quantity.value = 1
copiedInvoice.value = false
}
@ -274,25 +370,26 @@ onUnmounted(() => {
</DialogDescription>
</DialogHeader>
<!-- Authentication Check -->
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
<!-- Free tickets need an account (anti-abuse). Paid tickets don't. -->
<div v-if="guestNeedsLogin" class="py-4 text-center space-y-4">
<div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" />
</div>
<div class="space-y-2">
<h3 class="text-lg font-semibold">Login Required</h3>
<h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3>
<p class="text-sm text-muted-foreground">
Please log in to your account to purchase tickets using your wallet.
{{ t('events.purchase.loginRequiredFree') }}
</p>
</div>
<Button @click="handleClose" variant="outline">
Close
</Button>
<div class="flex gap-2 justify-center">
<Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button>
<Button @click="handleClose" variant="outline">Close</Button>
</div>
</div>
<!-- User Info and Purchase -->
<div v-else-if="!paymentHash" class="py-4 space-y-4">
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<!-- Contact + payment details, then submit -->
<form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit">
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span>
@ -309,8 +406,44 @@ onUnmounted(() => {
</div>
</div>
<!-- Wallet Information -->
<!-- Contact: who the ticket is for + where to send it -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Mail class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('events.purchase.contact.title') }}</span>
</div>
<FormField v-if="!isAuthenticated" v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.name') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" autocomplete="name" :placeholder="t('events.purchase.contact.namePlaceholder')" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.email') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormDescription class="text-xs">{{ t('events.purchase.contact.emailHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="confirmEmail">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.confirmEmail') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Wallet Information (logged-in Lightning buyers) -->
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span>
@ -329,7 +462,7 @@ onUnmounted(() => {
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div>
</div>
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary">
<Zap class="w-4 h-4" />
<span>Auto-payment available</span>
</div>
@ -347,7 +480,7 @@ onUnmounted(() => {
</div>
<!-- Quantity selector — backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. -->
the whole purchase, N ticket rows (one per attendee). -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2">
@ -392,18 +525,15 @@ onUnmounted(() => {
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
suffix=" if paid in fiat"
suffix=" if paid by card"
/>
</div>
</div>
<!-- Payment method selector (only shown when fiat is enabled
on the event). Buttons surface one per configured fiat
provider so "Stripe" / "PayPal" / "Square" stand alongside
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 && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<!-- Payment method selector — one button per enabled rail /
configured fiat provider. Hidden when only one rail is on
(nothing to choose) and for free tickets. -->
<div v-if="paymentMethods.length > 1 && !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.
@ -421,26 +551,9 @@ onUnmounted(() => {
{{ error || fiatError }}
</div>
<!-- Fiat checkout panel — shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div>
<Button
v-else
@click="handlePurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
type="submit"
:disabled="isLoading || isFiatPending || paymentMethods.length === 0 && !isFree"
class="w-full"
>
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
@ -450,14 +563,17 @@ onUnmounted(() => {
</template>
<template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout
{{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }}
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template>
</Button>
</div>
<p v-if="selectedMethod?.rail === 'fiat' && !isFree" class="text-xs text-muted-foreground text-center">
{{ t('events.purchase.fiatRedirectHint') }}
</p>
</form>
<!-- Lightning invoice — restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the
@ -468,8 +584,13 @@ onUnmounted(() => {
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
<template v-if="hasWalletWithBalance">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p>
</div>
@ -553,21 +674,20 @@ onUnmounted(() => {
</div>
</div>
<!-- Success state. QRs live in My Tickets — no need to
pre-render them here; this view's job is to confirm the
purchase landed and route the buyer to where they actually
interact with their tickets. -->
<!-- Success state. Logged-in buyers get routed to My Tickets
(QRs live there); guests have no My Tickets, so their QRs
render right here alongside the "emailed to" note. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" />
<Ticket class="w-12 h-12 text-primary" />
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">
<h3 class="text-lg font-semibold text-primary">
{{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }}
</h3>
<p class="text-sm text-muted-foreground">
<p v-if="isAuthenticated" class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets —
hand them out independently for the door scan.
@ -576,10 +696,31 @@ onUnmounted(() => {
Your ticket is now in My Tickets.
</span>
</p>
<p v-if="form.values.email" class="text-sm text-muted-foreground">
{{ t('events.checkout.emailedTo', { email: form.values.email }) }}
</p>
</div>
<div v-if="!isAuthenticated" class="w-full space-y-3">
<div
v-for="(id, idx) in purchasedTicketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="purchasedTicketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="ticketQRCodes[id]" :src="ticketQRCodes[id]" :alt="`Ticket ${id}`" class="block h-40 w-40" />
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
</div>
<div class="space-y-3 w-full">
<Button @click="() => $router.push('/my-tickets')" class="w-full">
<Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets
</Button>
<Button variant="outline" @click="handleClose" class="w-full">

View file

@ -0,0 +1,38 @@
/**
* What the buyer is waiting on while the tab is away at the fiat
* provider's checkout. Written right before the redirect; read back by
* EventDetailPage when Stripe returns with `?checkout=success` (the
* URL carries the ticket ids too — this record is the belt-and-braces
* copy that also remembers the delivery email for the confirmation
* screen). Cleared once the return dialog sees the tickets paid, or on
* `?checkout=cancelled`.
*/
export const PENDING_CHECKOUT_KEY = 'events:pending-checkout'
export interface PendingCheckout {
eventId: string
paymentHash: string
ticketIds: string[]
email?: string
ts: number
}
export function readPendingCheckout(): PendingCheckout | null {
try {
const raw = localStorage.getItem(PENDING_CHECKOUT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingCheckout
if (!parsed || typeof parsed.eventId !== 'string') return null
return parsed
} catch {
return null
}
}
export function clearPendingCheckout(): void {
try {
localStorage.removeItem(PENDING_CHECKOUT_KEY)
} catch {
// ignore
}
}