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.',
|
cancelled: 'Payment cancelled — no seats were reserved.',
|
||||||
viewTicket: 'Open ticket page',
|
viewTicket: 'Open ticket page',
|
||||||
viewMyTickets: 'View My Tickets',
|
viewMyTickets: 'View My Tickets',
|
||||||
|
retry: 'Check again',
|
||||||
ticketN: 'Ticket {n} of {total}',
|
ticketN: 'Ticket {n} of {total}',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ const messages: LocaleMessages = {
|
||||||
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
|
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
|
||||||
viewTicket: 'Abrir página del boleto',
|
viewTicket: 'Abrir página del boleto',
|
||||||
viewMyTickets: 'Ver mis boletos',
|
viewMyTickets: 'Ver mis boletos',
|
||||||
|
retry: 'Comprobar de nuevo',
|
||||||
ticketN: 'Boleto {n} de {total}',
|
ticketN: 'Boleto {n} de {total}',
|
||||||
close: 'Cerrar',
|
close: 'Cerrar',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ const messages: LocaleMessages = {
|
||||||
cancelled: 'Paiement annulé — aucune place réservée.',
|
cancelled: 'Paiement annulé — aucune place réservée.',
|
||||||
viewTicket: 'Ouvrir la page du billet',
|
viewTicket: 'Ouvrir la page du billet',
|
||||||
viewMyTickets: 'Voir mes billets',
|
viewMyTickets: 'Voir mes billets',
|
||||||
|
retry: 'Vérifier à nouveau',
|
||||||
ticketN: 'Billet {n} sur {total}',
|
ticketN: 'Billet {n} sur {total}',
|
||||||
close: 'Fermer',
|
close: 'Fermer',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ export interface LocaleMessages {
|
||||||
cancelled: string
|
cancelled: string
|
||||||
viewTicket: string
|
viewTicket: string
|
||||||
viewMyTickets: string
|
viewMyTickets: string
|
||||||
|
retry: string
|
||||||
ticketN: string
|
ticketN: string
|
||||||
close: 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 BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.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 { useAuth } from '@/composables/useAuthService'
|
||||||
import { useEventsStore } from '../stores/events'
|
import { useEventsStore } from '../stores/events'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
|
@ -116,6 +118,7 @@ const purchaseEvent = computed(() => {
|
||||||
currency: a.ticketInfo.currency,
|
currency: a.ticketInfo.currency,
|
||||||
allow_fiat: a.ticketInfo.allowFiat,
|
allow_fiat: a.ticketInfo.allowFiat,
|
||||||
fiat_currency: a.ticketInfo.fiatCurrency,
|
fiat_currency: a.ticketInfo.fiatCurrency,
|
||||||
|
payment_methods: a.ticketInfo.paymentMethods,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -142,7 +145,10 @@ const isPast = computed(() => {
|
||||||
const showPurchaseDialog = ref(false)
|
const showPurchaseDialog = ref(false)
|
||||||
|
|
||||||
function openPurchaseDialog() {
|
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'), {
|
toastService.info(t('events.detail.loginToBuyTickets'), {
|
||||||
action: {
|
action: {
|
||||||
label: t('events.detail.logIn'),
|
label: t('events.detail.logIn'),
|
||||||
|
|
@ -154,6 +160,43 @@ function openPurchaseDialog() {
|
||||||
showPurchaseDialog.value = true
|
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
|
// Re-fetch the user's tickets when the purchase dialog closes (the
|
||||||
// buyer may have just paid). The inventory side updates automatically
|
// buyer may have just paid). The inventory side updates automatically
|
||||||
// via the relay republish from the events extension.
|
// via the relay republish from the events extension.
|
||||||
|
|
@ -366,6 +409,14 @@ function goToMyTickets() {
|
||||||
@update:is-open="showPurchaseDialog = $event"
|
@update:is-open="showPurchaseDialog = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CheckoutReturnDialog
|
||||||
|
:is-open="showReturnDialog"
|
||||||
|
:event-id="eventId"
|
||||||
|
:ticket-ids="returnTicketIds"
|
||||||
|
:email="returnEmail"
|
||||||
|
@update:is-open="showReturnDialog = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- External references -->
|
<!-- External references -->
|
||||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||||
<!-- References would go here in future phases -->
|
<!-- References would go here in future phases -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue