feat(events): guest checkout with email delivery, Stripe return flow, public ticket page #144
6 changed files with 238 additions and 1 deletions
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
commit
bd78e3227d
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export interface LocaleMessages {
|
|||
cancelled: string
|
||||
viewTicket: string
|
||||
viewMyTickets: string
|
||||
retry: string
|
||||
ticketN: string
|
||||
close: string
|
||||
}
|
||||
|
|
|
|||
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal file
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal 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>
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue