feat(events): guest checkout with email delivery, Stripe return flow, public ticket page #144
2 changed files with 167 additions and 0 deletions
feat(events): public ticket page at /events/ticket/:ticketId
Target of the emailed ticket link the extension now builds under the
webapp root. Anonymous, like the LNbits UI's own /events/ticket/{id}:
reads the PublicTicket, resolves the event name (Nostr store → scoped
relay query → public REST as a last resort), and renders the logo QR
with paid / pending / checked-in state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
commit
b853fbce4b
|
|
@ -51,6 +51,18 @@ export const eventsModule = createModulePlugin({
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Public ticket page — target of the emailed ticket link. Listed
|
||||||
|
// before /events/:id for readability; the router can't confuse the
|
||||||
|
// two anyway (different segment counts).
|
||||||
|
path: '/events/ticket/:ticketId',
|
||||||
|
name: 'event-ticket',
|
||||||
|
component: () => import('./views/TicketPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Ticket',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/events/:id',
|
path: '/events/:id',
|
||||||
name: 'event-detail',
|
name: 'event-detail',
|
||||||
|
|
|
||||||
155
src/modules/events/views/TicketPage.vue
Normal file
155
src/modules/events/views/TicketPage.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ArrowLeft, Ticket, CheckCircle2, Clock, CreditCard, Loader2, AlertCircle } from 'lucide-vue-next'
|
||||||
|
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||||
|
import { useEventsStore } from '../stores/events'
|
||||||
|
import { renderTicketQr } from '../lib/ticketQr'
|
||||||
|
import type { PublicTicket } from '../types/ticket'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public ticket page — where the emailed link lands
|
||||||
|
* (`<frontend_url>/events/ticket/{ticket_id}`). Anonymous: the ticket
|
||||||
|
* id in the URL is the door credential, the same token the QR carries,
|
||||||
|
* exactly like the LNbits UI's own /events/ticket/{id} page.
|
||||||
|
*/
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const ticketId = route.params.ticketId as string
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
const store = useEventsStore()
|
||||||
|
|
||||||
|
const ticket = ref<PublicTicket | null>(null)
|
||||||
|
const eventName = ref<string | null>(null)
|
||||||
|
const qr = ref<string | null>(null)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const notFound = ref(false)
|
||||||
|
|
||||||
|
const status = computed(() => {
|
||||||
|
if (!ticket.value) return null
|
||||||
|
if (!ticket.value.paid) return { label: t('events.ticket.pending'), icon: Clock, variant: 'secondary' as const }
|
||||||
|
if (ticket.value.registered) return { label: t('events.ticket.registered'), icon: CheckCircle2, variant: 'default' as const }
|
||||||
|
return { label: t('events.ticket.paid'), icon: CreditCard, variant: 'default' as const }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event name resolution, cheapest first: the Nostr store (d-tag ==
|
||||||
|
// LNbits event id), a scoped relay query, then the public REST event
|
||||||
|
// (which 410s for sold-out / closed events — non-fatal here).
|
||||||
|
async function resolveEventName(eventId: string) {
|
||||||
|
const cached = store.getEventById(eventId)
|
||||||
|
if (cached) {
|
||||||
|
eventName.value = cached.title
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nostr = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
|
if (nostr) {
|
||||||
|
try {
|
||||||
|
const results = await nostr.queryCalendarEvents({ dTags: [eventId] })
|
||||||
|
store.upsertEvents(results)
|
||||||
|
const hit = store.getEventById(eventId)
|
||||||
|
if (hit) {
|
||||||
|
eventName.value = hit.title
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pub = await ticketApi.getPublicEvent(eventId)
|
||||||
|
eventName.value = pub.name
|
||||||
|
} catch {
|
||||||
|
eventName.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
ticket.value = await ticketApi.getTicket(ticketId)
|
||||||
|
} catch {
|
||||||
|
notFound.value = true
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading.value = false
|
||||||
|
void resolveEventName(ticket.value.eventId)
|
||||||
|
try {
|
||||||
|
qr.value = await renderTicketQr(ticketId, { width: 240 })
|
||||||
|
} catch {
|
||||||
|
qr.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToEvent() {
|
||||||
|
if (ticket.value) router.push(`/events/${ticket.value.eventId}`)
|
||||||
|
else router.push('/events')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto py-6 px-4 max-w-md">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<Button variant="ghost" size="sm" class="gap-1.5" @click="goToEvent">
|
||||||
|
<ArrowLeft class="w-4 h-4" />
|
||||||
|
{{ t('events.ticket.backToEvent') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2 class="w-8 h-8 animate-spin" />
|
||||||
|
<span class="text-sm">{{ t('events.ticket.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="notFound" class="py-16 flex flex-col items-center gap-3 text-center">
|
||||||
|
<AlertCircle class="w-10 h-10 text-destructive" />
|
||||||
|
<p class="text-sm text-muted-foreground">{{ t('events.ticket.notFound') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ticket" class="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Ticket class="w-5 h-5 shrink-0" />
|
||||||
|
{{ t('events.ticket.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground truncate">
|
||||||
|
{{ eventName ?? t('events.ticket.event') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge v-if="status" :variant="status.variant" class="gap-1 shrink-0">
|
||||||
|
<component :is="status.icon" class="w-3.5 h-3.5" />
|
||||||
|
{{ status.label }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img v-if="qr" :src="qr" :alt="`Ticket ${ticketId}`" class="block h-60 w-60" />
|
||||||
|
<div v-else class="h-60 w-60 flex items-center justify-center">
|
||||||
|
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div v-if="ticket.name" class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">{{ t('events.ticket.holder') }}</span>
|
||||||
|
<span class="font-medium truncate">{{ ticket.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">ID</span>
|
||||||
|
<span class="font-mono text-xs">{{ ticketId }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
|
{{ ticket.paid ? t('events.ticket.showAtDoor') : t('events.ticket.pending') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue