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:
parent
f85848a50b
commit
891b5072f8
6 changed files with 495 additions and 136 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
38
src/modules/events/lib/pendingCheckout.ts
Normal file
38
src/modules/events/lib/pendingCheckout.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue