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
This commit is contained in:
Padreug 2026-09-06 19:35:45 +02:00
commit 891b5072f8
6 changed files with 495 additions and 136 deletions

View file

@ -129,6 +129,51 @@ const messages: LocaleMessages = {
pending: 'Pending', pending: 'Pending',
registered: 'Registered', 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: { nav: {
feed: 'Home', feed: 'Home',
calendar: 'Calendar', calendar: 'Calendar',

View file

@ -129,6 +129,51 @@ const messages: LocaleMessages = {
pending: 'Pendiente', pending: 'Pendiente',
registered: 'Registrado', 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: { nav: {
feed: 'Inicio', feed: 'Inicio',
calendar: 'Calendario', calendar: 'Calendario',

View file

@ -129,6 +129,51 @@ const messages: LocaleMessages = {
pending: 'En attente', pending: 'En attente',
registered: 'Enregistré', 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: { nav: {
feed: 'Accueil', feed: 'Accueil',
calendar: 'Calendrier', calendar: 'Calendrier',

View file

@ -104,6 +104,51 @@ export interface LocaleMessages {
pending: string pending: string
registered: 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: { nav: {
feed: string feed: string
calendar: string calendar: string

View file

@ -1,13 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' 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 { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' 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 { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, { import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry, type PaymentMethod as PaymentMethodEntry,
@ -15,6 +28,9 @@ import PaymentMethodSelector, {
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue' import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders' import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion' 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 { interface Props {
event: { event: {
@ -25,6 +41,9 @@ interface Props {
/** Whether the event accepts fiat payments. From v1.4.0+ */ /** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean allow_fiat?: boolean
fiat_currency?: string 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 isOpen: boolean
} }
@ -35,6 +54,7 @@ interface Emits {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { t } = useI18n()
const { isAuthenticated, userDisplay, currentUser } = useAuth() const { isAuthenticated, userDisplay, currentUser } = useAuth()
const { const {
@ -45,7 +65,6 @@ const {
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
canPurchase,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
@ -54,6 +73,7 @@ const {
resetPaymentState, resetPaymentState,
cleanup, cleanup,
purchasedTicketIds, purchasedTicketIds,
ticketQRCodes,
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
@ -75,6 +95,11 @@ const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// and label the CTA "Get ticket" instead of "Proceed". // and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0) 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() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { 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 { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion() const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning') const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false) const isFiatPending = ref(false)
const fiatError = ref<string | null>(null) 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( const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats', () => props.event.currency === 'sat' || props.event.currency === 'sats',
) )
@ -124,7 +199,9 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
} }
const paymentMethods = computed<PaymentMethodEntry[]>(() => { const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = { const entries: PaymentMethodEntry[] = []
if (enabledRails.value.includes('lightning')) {
entries.push({
id: 'lightning', id: 'lightning',
rail: 'lightning', rail: 'lightning',
label: 'Lightning', label: 'Lightning',
@ -134,115 +211,134 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
!isPriceInSats.value && lightningSats.value !isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats` ? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined, : undefined,
})
} }
if (!props.event.allow_fiat) return [lightning] if (canChooseFiat.value) {
if (providers.value.length > 0) { if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => { for (const id of providers.value) {
const meta = providerMeta(id) const meta = providerMeta(id)
return { entries.push({
id: `fiat:${id}`, id: `fiat:${id}`,
rail: 'fiat', rail: 'fiat',
provider: id, provider: id,
label: meta.label, label: meta.label,
icon: iconFor(meta.icon), icon: iconFor(meta.icon),
available: true, available: true,
}
}) })
return [lightning, ...fiatRails]
} }
} else {
// Degenerate fallback — allow_fiat is on but the buyer's session // Guests (and sessions without provider info) can't enumerate the
// can't enumerate the organizer's providers. Show a generic Card // organizer's providers. Show a generic Card button and let the
// button and let the backend pick a default at request time. // backend pick the organizer's default at request time.
return [ entries.push({
lightning,
{
id: 'fiat', id: 'fiat',
rail: 'fiat', rail: 'fiat',
label: 'Card', label: t('events.purchase.card'),
icon: CreditCard, icon: CreditCard,
available: true, 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(() => const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0], paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
) )
async function handlePurchase() { const onSubmit = form.handleSubmit(async (contact) => {
if (!canPurchase.value) return await handlePurchase(contact)
fiatError.value = null })
async function handlePurchase(contact: { name?: string; email: string }) {
fiatError.value = null
const method = selectedMethod.value const method = selectedMethod.value
if (!method) return if (!method) return
// Lightning path: the composable just creates the invoice + starts const isGuest = !isAuthenticated.value
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here. // 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') { if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value }) await purchaseTicketForEvent(props.event.id, {
quantity: quantity.value,
contact: guestContact,
email: contact.email,
})
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
return return
} }
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the // Fiat path: POST with the chosen provider, remember what we're
// API directly with the chosen provider, then redirect the buyer to // waiting for, then hand the tab to the provider's checkout. Stripe
// the provider's checkout URL. Payment confirmation happens via // brings the buyer back to the event page with ?checkout=success&
// webhook on the backend and shows up next time the buyer reloads // tickets=… (or ?checkout=cancelled) — see EventDetailPage.
// MyTickets.
try { try {
isFiatPending.value = true isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any 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 ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const userId = currentUser.value?.id const invoice = await ticketApi.requestTicket(props.event.id, {
if (!userId) { userId: isGuest ? undefined : currentUser.value?.id,
fiatError.value = 'Missing user id' name: guestContact?.name,
return email: contact.email,
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken, accessToken,
{ frontendUrl: frontendRoot(),
paymentMethod: 'fiat', paymentMethod: 'fiat',
fiatProvider: method.provider, fiatProvider: method.provider,
quantity: quantity.value, quantity: quantity.value,
}, })
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) { 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 return
} }
fiatRedirectUrl.value = invoice.fiatPaymentRequest const pending: PendingCheckout = {
fiatProviderLabel.value = invoice.fiatProvider eventId: props.event.id,
? providerMeta(invoice.fiatProvider).label paymentHash: invoice.paymentHash,
: method.label 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) { } catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed' fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
} finally {
isFiatPending.value = false isFiatPending.value = false
} }
} }
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
selectedMethodId.value = 'lightning' selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null fiatError.value = null
isFiatPending.value = false
quantity.value = 1 quantity.value = 1
copiedInvoice.value = false copiedInvoice.value = false
} }
@ -274,25 +370,26 @@ onUnmounted(() => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<!-- Authentication Check --> <!-- Free tickets need an account (anti-abuse). Paid tickets don't. -->
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4"> <div v-if="guestNeedsLogin" class="py-4 text-center space-y-4">
<div class="flex justify-center"> <div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" /> <User class="w-12 h-12 text-muted-foreground" />
</div> </div>
<div class="space-y-2"> <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"> <p class="text-sm text-muted-foreground">
Please log in to your account to purchase tickets using your wallet. {{ t('events.purchase.loginRequiredFree') }}
</p> </p>
</div> </div>
<Button @click="handleClose" variant="outline"> <div class="flex gap-2 justify-center">
Close <Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button>
</Button> <Button @click="handleClose" variant="outline">Close</Button>
</div>
</div> </div>
<!-- User Info and Purchase --> <!-- Contact + payment details, then submit -->
<div v-else-if="!paymentHash" class="py-4 space-y-4"> <form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit">
<div class="bg-muted/50 rounded-lg p-4 space-y-3"> <div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" /> <User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span> <span class="text-sm font-medium">Purchasing as:</span>
@ -309,8 +406,44 @@ onUnmounted(() => {
</div> </div>
</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="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"> <div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" /> <Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span> <span class="text-sm font-medium">Wallet Status:</span>
@ -329,7 +462,7 @@ onUnmounted(() => {
<Badge v-else variant="secondary" class="text-xs">Empty</Badge> <Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div> </div>
</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" /> <Zap class="w-4 h-4" />
<span>Auto-payment available</span> <span>Auto-payment available</span>
</div> </div>
@ -347,7 +480,7 @@ onUnmounted(() => {
</div> </div>
<!-- Quantity selector — backend caps at 10. One invoice for <!-- 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"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span> <span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -392,18 +525,15 @@ onUnmounted(() => {
from="sat" from="sat"
:to="event.fiat_currency" :to="event.fiat_currency"
prefix="Equivalent ~" prefix="Equivalent ~"
suffix=" if paid in fiat" suffix=" if paid by card"
/> />
</div> </div>
</div> </div>
<!-- Payment method selector (only shown when fiat is enabled <!-- Payment method selector — one button per enabled rail /
on the event). Buttons surface one per configured fiat configured fiat provider. Hidden when only one rail is on
provider so "Stripe" / "PayPal" / "Square" stand alongside (nothing to choose) and for free tickets. -->
Lightning rather than collapsing into a single "Fiat" <div v-if="paymentMethods.length > 1 && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
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">
<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.
@ -421,26 +551,9 @@ onUnmounted(() => {
{{ error || fiatError }} {{ error || fiatError }}
</div> </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 <Button
v-else type="submit"
@click="handlePurchase" :disabled="isLoading || isFiatPending || paymentMethods.length === 0 && !isFree"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
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" />
@ -450,14 +563,17 @@ onUnmounted(() => {
</template> </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 {{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }}
</template> </template>
<template v-else> <template v-else>
<Zap class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }} {{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template> </template>
</Button> </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, <!-- Lightning invoice — restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the with both pay paths visible at once: tap-to-pay from the
@ -468,8 +584,13 @@ onUnmounted(() => {
<div class="text-center space-y-1"> <div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3> <h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<template v-if="hasWalletWithBalance">
Scan with any Lightning wallet, or tap the button below to Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet. pay from your LNbits wallet.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p> </p>
</div> </div>
@ -553,21 +674,20 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Success state. QRs live in My Tickets — no need to <!-- Success state. Logged-in buyers get routed to My Tickets
pre-render them here; this view's job is to confirm the (QRs live there); guests have no My Tickets, so their QRs
purchase landed and route the buyer to where they actually render right here alongside the "emailed to" note. -->
interact with their tickets. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4"> <div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center"> <div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" /> <Ticket class="w-12 h-12 text-primary" />
</div> </div>
<div class="text-center space-y-2"> <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 > 1
? `${purchasedTicketIds.length} tickets purchased!` ? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }} : 'Ticket purchased!' }}
</h3> </h3>
<p class="text-sm text-muted-foreground"> <p v-if="isAuthenticated" class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1"> <span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets — Each attendee gets their own scannable QR in My Tickets —
hand them out independently for the door scan. hand them out independently for the door scan.
@ -576,10 +696,31 @@ onUnmounted(() => {
Your ticket is now in My Tickets. Your ticket is now in My Tickets.
</span> </span>
</p> </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>
<div class="space-y-3 w-full"> <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 View My Tickets
</Button> </Button>
<Button variant="outline" @click="handleClose" class="w-full"> <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
}
}