From bd78e3227d80cddd022a8ad95cae65c1f29bc3fd Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:37:52 +0200 Subject: [PATCH] feat(events): handle the return from fiat checkout on the event page Stripe now brings the buyer back to the event page with ?checkout=success&tickets= or ?checkout=cancelled. The page strips the query, then opens CheckoutReturnDialog, which polls the anonymous ticket endpoint until every row is paid (60 s budget with a retry), and renders one logo QR per ticket with a link to its public ticket page and the "emailed to" note from the pending-checkout record. Logged-in buyers also get their owned tickets refreshed and a My Tickets shortcut. The login toast on "Buy ticket" now only fires for free events; paid events open the dialog for guests. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- src/i18n/locales/en.ts | 1 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/types.ts | 1 + .../components/CheckoutReturnDialog.vue | 182 ++++++++++++++++++ src/modules/events/views/EventDetailPage.vue | 53 ++++- 6 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/modules/events/components/CheckoutReturnDialog.vue diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b718299..19becc3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -159,6 +159,7 @@ const messages: LocaleMessages = { cancelled: 'Payment cancelled — no seats were reserved.', viewTicket: 'Open ticket page', viewMyTickets: 'View My Tickets', + retry: 'Check again', ticketN: 'Ticket {n} of {total}', close: 'Close', }, diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 7410846..7bb588b 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -159,6 +159,7 @@ const messages: LocaleMessages = { cancelled: 'Pago cancelado — no se reservó ningún asiento.', viewTicket: 'Abrir página del boleto', viewMyTickets: 'Ver mis boletos', + retry: 'Comprobar de nuevo', ticketN: 'Boleto {n} de {total}', close: 'Cerrar', }, diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 21392ec..28dd564 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -159,6 +159,7 @@ const messages: LocaleMessages = { cancelled: 'Paiement annulé — aucune place réservée.', viewTicket: 'Ouvrir la page du billet', viewMyTickets: 'Voir mes billets', + retry: 'Vérifier à nouveau', ticketN: 'Billet {n} sur {total}', close: 'Fermer', }, diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 61a8f32..1dbb181 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -134,6 +134,7 @@ export interface LocaleMessages { cancelled: string viewTicket: string viewMyTickets: string + retry: string ticketN: string close: string } diff --git a/src/modules/events/components/CheckoutReturnDialog.vue b/src/modules/events/components/CheckoutReturnDialog.vue new file mode 100644 index 0000000..c09571b --- /dev/null +++ b/src/modules/events/components/CheckoutReturnDialog.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/modules/events/views/EventDetailPage.vue b/src/modules/events/views/EventDetailPage.vue index 10fdbc8..ee08ae8 100644 --- a/src/modules/events/views/EventDetailPage.vue +++ b/src/modules/events/views/EventDetailPage.vue @@ -14,6 +14,8 @@ import { useEventDetail } from '../composables/useEventDetail' import BookmarkButton from '../components/BookmarkButton.vue' import OrganizerCard from '../components/OrganizerCard.vue' import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' +import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue' +import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout' import { useAuth } from '@/composables/useAuthService' import { useEventsStore } from '../stores/events' import { useOwnedTickets } from '../composables/useOwnedTickets' @@ -116,6 +118,7 @@ const purchaseEvent = computed(() => { currency: a.ticketInfo.currency, allow_fiat: a.ticketInfo.allowFiat, fiat_currency: a.ticketInfo.fiatCurrency, + payment_methods: a.ticketInfo.paymentMethods, } }) @@ -142,7 +145,10 @@ const isPast = computed(() => { const showPurchaseDialog = ref(false) function openPurchaseDialog() { - if (!isAuthenticated.value) { + // Paid tickets are open to guests (name + email identity). Free + // claims still need an account so seats can't be drained anonymously. + const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0 + if (!isAuthenticated.value && isFreeEvent) { toastService.info(t('events.detail.loginToBuyTickets'), { action: { label: t('events.detail.logIn'), @@ -154,6 +160,43 @@ function openPurchaseDialog() { showPurchaseDialog.value = true } +// --- Return from the fiat provider --------------------------------- +// Stripe sends the buyer back here with ?checkout=success&tickets=a,b +// (ids pre-generated by the extension before the session was created) +// or ?checkout=cancelled. The query is stripped immediately so the +// ticket ids don't linger in history; the localStorage record written +// before the redirect is the fallback source and carries the email. +const showReturnDialog = ref(false) +const returnTicketIds = ref([]) +const returnEmail = ref(undefined) + +function handleCheckoutReturn() { + const status = route.query.checkout + if (status !== 'success' && status !== 'cancelled') return + + const pending = readPendingCheckout() + const samePurchase = pending?.eventId === eventId + void router.replace({ query: {} }) + + if (status === 'cancelled') { + clearPendingCheckout() + toastService.info(t('events.checkout.cancelled')) + return + } + + const fromQuery = String(route.query.tickets ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [] + if (ids.length === 0) return + returnTicketIds.value = ids + returnEmail.value = samePurchase ? pending?.email : undefined + showReturnDialog.value = true +} + +onMounted(handleCheckoutReturn) + // Re-fetch the user's tickets when the purchase dialog closes (the // buyer may have just paid). The inventory side updates automatically // via the relay republish from the events extension. @@ -366,6 +409,14 @@ function goToMyTickets() { @update:is-open="showPurchaseDialog = $event" /> + +