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

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

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=<ids> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Padreug 2026-09-06 19:37:52 +02:00

View file

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

View file

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

View file

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

View file

@ -134,6 +134,7 @@ export interface LocaleMessages {
cancelled: string
viewTicket: string
viewMyTickets: string
retry: string
ticketN: string
close: string
}

View file

@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Ticket, Loader2, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { renderTicketQr } from '../lib/ticketQr'
import { clearPendingCheckout } from '../lib/pendingCheckout'
/**
* Shown when the buyer lands back on the event page from the fiat
* provider with `?checkout=success&tickets=…`. Polls the anonymous
* ticket endpoint until every row is paid (Stripe waits for our
* webhook before redirecting, so the first poll usually already
* says paid), then renders one scannable QR per ticket.
*/
const props = defineProps<{
isOpen: boolean
eventId: string
ticketIds: string[]
email?: string
}>()
const emit = defineEmits<{ (e: 'update:isOpen', value: boolean): void }>()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { refresh: refreshOwnedTickets } = useOwnedTickets()
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const POLL_MS = 2000
const TIMEOUT_MS = 60_000
type Phase = 'checking' | 'paid' | 'timeout'
const phase = ref<Phase>('checking')
const qrCodes = ref<Record<string, string>>({})
let timer: number | null = null
let startedAt = 0
const paidCount = ref(0)
async function checkOnce(): Promise<boolean> {
const results = await Promise.all(
props.ticketIds.map((id) => ticketApi.getTicket(id).then((t) => t.paid).catch(() => false)),
)
paidCount.value = results.filter(Boolean).length
return results.length > 0 && results.every(Boolean)
}
async function onPaid() {
phase.value = 'paid'
stop()
clearPendingCheckout()
if (isAuthenticated.value) void refreshOwnedTickets()
const map: Record<string, string> = {}
for (const id of props.ticketIds) {
try {
map[id] = await renderTicketQr(id, { width: 200 })
} catch {
// leave the slot empty; the ticket page link still works
}
}
qrCodes.value = map
}
async function tick() {
if (phase.value !== 'checking') return
try {
if (await checkOnce()) {
await onPaid()
return
}
} catch {
// transient network error — keep polling until the timeout
}
if (Date.now() - startedAt > TIMEOUT_MS) {
phase.value = 'timeout'
stop()
}
}
function start() {
stop()
phase.value = 'checking'
qrCodes.value = {}
startedAt = Date.now()
void tick()
timer = window.setInterval(() => void tick(), POLL_MS)
}
function stop() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
watch(
() => [props.isOpen, props.ticketIds.join(',')] as const,
([open]) => {
if (open && props.ticketIds.length > 0) start()
else stop()
},
{ immediate: true },
)
onUnmounted(stop)
function close() {
stop()
emit('update:isOpen', false)
}
const title = computed(() =>
phase.value === 'paid' ? t('events.checkout.paidTitle') : t('events.checkout.title'),
)
</script>
<template>
<Dialog :open="isOpen" @update:open="close">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Ticket class="w-5 h-5" />
{{ title }}
</DialogTitle>
<DialogDescription v-if="phase === 'paid' && email">
{{ t('events.checkout.emailedTo', { email }) }}
</DialogDescription>
</DialogHeader>
<div v-if="phase === 'checking'" class="py-8 flex flex-col items-center gap-3 text-center">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">{{ t('events.checkout.processing') }}</p>
<p v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
{{ paidCount }} / {{ ticketIds.length }}
</p>
</div>
<div v-else-if="phase === 'timeout'" class="py-6 flex flex-col items-center gap-3 text-center">
<Clock class="w-10 h-10 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
{{ email
? t('events.checkout.stillProcessing', { email })
: t('events.checkout.stillProcessingNoEmail') }}
</p>
<Button variant="outline" @click="start">{{ t('events.checkout.retry') }}</Button>
</div>
<div v-else class="py-4 space-y-3">
<div
v-for="(id, idx) in ticketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: ticketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="qrCodes[id]" :src="qrCodes[id]" :alt="`Ticket ${id}`" class="block h-48 w-48" />
<div v-else class="h-48 w-48 flex items-center justify-center">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
<p class="text-xs text-muted-foreground text-center">{{ t('events.ticket.showAtDoor') }}</p>
<div class="space-y-2 pt-1">
<Button v-if="isAuthenticated" class="w-full" @click="() => { close(); $router.push('/my-tickets') }">
{{ t('events.checkout.viewMyTickets') }}
</Button>
<Button variant="outline" class="w-full" @click="close">{{ t('events.checkout.close') }}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -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<string[]>([])
const returnEmail = ref<string | undefined>(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"
/>
<CheckoutReturnDialog
:is-open="showReturnDialog"
:event-id="eventId"
:ticket-ids="returnTicketIds"
:email="returnEmail"
@update:is-open="showReturnDialog = $event"
/>
<!-- External references -->
<div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->