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',
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
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