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
26 changed files with 1576 additions and 230 deletions

View file

@ -0,0 +1,76 @@
# Events — guest checkout, fiat return flow and ticket delivery
Buyers can purchase **paid** tickets without an LNbits account. This note is
the wire contract between the webapp and the `aiolabs/events` extension
(≥ v1.6.1-aio.8) for that flow. Free tickets still require a login
(events#29: no anonymous free issuance).
## Identity on `POST /events/api/v1/tickets/{event_id}`
| Buyer | Body fields | Auth header |
|---|---|---|
| Logged in | `user_id` + `email` (delivery address, prefilled from the account, editable) | `Authorization: Bearer <token>` |
| Guest | `name` + `email` | none (the endpoint is anonymous; the instance `X-API-KEY` is always sent) |
Every purchase also sends `frontend_url` — this app's root
(`window.location.origin + import.meta.env.BASE_URL`, no trailing slash;
`src/modules/events/lib/frontendRoot.ts`). The extension only accepts it when
its origin is on the LNbits `LNBITS_CORS_ALLOWED_ORIGINS` allowlist (or the
LNbits base / custom-frontend origin); otherwise the request fails with
`frontend_url origin is not allowed.` and the dialog shows that message.
`payment_method` is `lightning` or `fiat`. The event's enabled rails come from
the NIP-52 tag `tickets_payment_methods` (`"lightning,fiat"`); when the tag is
absent the legacy rule applies (Lightning always, fiat iff
`tickets_allow_fiat`). See `lib/paymentMethods.ts`. A rail that is off is
rejected by the backend with `Payment method not enabled for this event.`
## Return from the fiat provider
The extension composes both Stripe URLs under `frontend_url`:
| Purpose | URL |
|---|---|
| success | `<root>/events/{event_id}?checkout=success&tickets=<id1,id2>` |
| cancel | `<root>/events/{event_id}?checkout=cancelled` |
Before redirecting (same tab, `window.location.assign`), the purchase dialog
writes `localStorage['events:pending-checkout']` =
`{ eventId, paymentHash, ticketIds, email, ts }` (`lib/pendingCheckout.ts`).
`EventDetailPage` strips the query on arrival, then:
- `success` → opens `CheckoutReturnDialog`, which polls the anonymous
`GET /events/api/v1/tickets/{ticket_id}` for every id every 2 s (60 s budget,
retry button) until all are `paid`, then renders one QR per ticket, the
"emailed to" note (email from the localStorage record) and a link to the
ticket page. Logged-in buyers also get `useOwnedTickets().refresh()`.
- `cancelled` → info toast, record cleared. Nothing was charged; the
extension purges unpaid rows after 24 h.
## Public ticket page
`/events/ticket/:ticketId` (`views/TicketPage.vue`, `requiresAuth: false`) is
where the emailed link lands. It reads the `PublicTicket` (no id / email /
user_id in the payload; the id in the URL is the door credential, exactly as
in the LNbits UI), resolves the event name (Nostr store → scoped relay query
→ public REST, which 410s on sold-out events and is treated as non-fatal) and
renders the QR.
## QR rendering
All `ticket://<id>` QRs (purchase success, return dialog, ticket page, My
Tickets) go through `lib/ticketQr.ts`: error-correction level H with the
brand logo (`@brand/logo.png`) centred at ≤ 20 % width on a white pad, so the
in-app image matches the PNG the extension embeds in the ticket email
(`GET /events/api/v1/qr/{ticket_id}`). Canvas failures fall back to the plain
QR.
## Organizer side
`CreateEventDialog` publishes `extra.payment_methods` (Lightning / Card
checkboxes; at least one) and keeps `allow_fiat` in sync for older readers.
Card is disabled with a tooltip when the organizer's LNbits user has no fiat
provider (`useFiatProviders().hasAnyProvider`).
`resendTicketEmail` returns the structured `TicketResendResult`
(`{ ticket, email: {attempted, sent, error}, nostr: {...} }`).

View file

@ -182,6 +182,13 @@ interface ModulePlugin {
**See:** [[market-module/index|📖 Market Module Documentation]] **See:** [[market-module/index|📖 Market Module Documentation]]
### **Events Module** 🎟️
**Purpose:** Ticketed events (Nostr NIP-52 feed + LNbits events extension)
**Location:** `src/modules/events/`
**Dependencies:** `['base']`
**See:** [[events-module/guest-checkout|📖 Guest checkout, fiat return flow and ticket delivery]]
## Module Development ## Module Development
### **Creating a New Module** ### **Creating a New Module**

View file

@ -129,6 +129,52 @@ const messages: LocaleMessages = {
pending: 'Pending', pending: 'Pending',
registered: 'Registered', registered: 'Registered',
}, },
purchase: {
card: 'Card',
continueTo: 'Continue to {provider} checkout',
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.",
noCheckoutUrl: 'Fiat provider did not return a checkout URL.',
fiatFailed: 'Card checkout failed',
loginRequiredTitle: 'Login required',
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
contact: {
title: 'Your details',
name: 'Name',
namePlaceholder: 'Name on the ticket',
nameRequired: 'Name is required',
email: 'Email',
emailHint: 'Your ticket will be sent to this address.',
emailInvalid: 'Enter a valid email address',
confirmEmail: 'Confirm email',
emailMismatch: 'Email addresses do not match',
},
},
checkout: {
title: 'Confirming your payment…',
paidTitle: 'Payment received',
processing: 'Waiting for the payment to settle. This usually takes a few seconds.',
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.',
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.',
emailedTo: 'We emailed your ticket to {email}.',
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',
},
ticket: {
title: 'Your ticket',
loading: 'Loading ticket…',
notFound: 'Ticket not found',
event: 'Event',
holder: 'Name',
paid: 'Paid',
pending: 'Payment pending',
registered: 'Checked in',
showAtDoor: 'Show this QR at the door.',
backToEvent: 'Back to event',
},
nav: { nav: {
feed: 'Home', feed: 'Home',
calendar: 'Calendar', calendar: 'Calendar',

View file

@ -129,6 +129,52 @@ const messages: LocaleMessages = {
pending: 'Pendiente', pending: 'Pendiente',
registered: 'Registrado', registered: 'Registrado',
}, },
purchase: {
card: 'Tarjeta',
continueTo: 'Continuar al pago con {provider}',
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.',
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.',
fiatFailed: 'El pago con tarjeta falló',
loginRequiredTitle: 'Inicio de sesión requerido',
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
contact: {
title: 'Tus datos',
name: 'Nombre',
namePlaceholder: 'Nombre en el boleto',
nameRequired: 'El nombre es obligatorio',
email: 'Correo electrónico',
emailHint: 'Tu boleto se enviará a esta dirección.',
emailInvalid: 'Introduce un correo electrónico válido',
confirmEmail: 'Confirmar correo',
emailMismatch: 'Los correos no coinciden',
},
},
checkout: {
title: 'Confirmando tu pago…',
paidTitle: 'Pago recibido',
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.',
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.',
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.',
emailedTo: 'Enviamos tu boleto a {email}.',
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',
},
ticket: {
title: 'Tu boleto',
loading: 'Cargando boleto…',
notFound: 'Boleto no encontrado',
event: 'Evento',
holder: 'Nombre',
paid: 'Pagado',
pending: 'Pago pendiente',
registered: 'Registrado',
showAtDoor: 'Muestra este QR en la entrada.',
backToEvent: 'Volver al evento',
},
nav: { nav: {
feed: 'Inicio', feed: 'Inicio',
calendar: 'Calendario', calendar: 'Calendario',

View file

@ -129,6 +129,52 @@ const messages: LocaleMessages = {
pending: 'En attente', pending: 'En attente',
registered: 'Enregistré', registered: 'Enregistré',
}, },
purchase: {
card: 'Carte',
continueTo: 'Continuer vers le paiement {provider}',
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.',
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
fiatFailed: 'Le paiement par carte a échoué',
loginRequiredTitle: 'Connexion requise',
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
contact: {
title: 'Vos coordonnées',
name: 'Nom',
namePlaceholder: 'Nom sur le billet',
nameRequired: 'Le nom est obligatoire',
email: 'E-mail',
emailHint: 'Votre billet sera envoyé à cette adresse.',
emailInvalid: 'Saisissez une adresse e-mail valide',
confirmEmail: "Confirmer l'e-mail",
emailMismatch: 'Les adresses e-mail ne correspondent pas',
},
},
checkout: {
title: 'Confirmation de votre paiement…',
paidTitle: 'Paiement reçu',
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.',
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.',
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.',
emailedTo: 'Nous avons envoyé votre billet à {email}.',
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',
},
ticket: {
title: 'Votre billet',
loading: 'Chargement du billet…',
notFound: 'Billet introuvable',
event: 'Événement',
holder: 'Nom',
paid: 'Payé',
pending: 'Paiement en attente',
registered: 'Enregistré',
showAtDoor: "Présentez ce QR à l'entrée.",
backToEvent: "Retour à l'événement",
},
nav: { nav: {
feed: 'Accueil', feed: 'Accueil',
calendar: 'Calendrier', calendar: 'Calendrier',

View file

@ -104,6 +104,52 @@ export interface LocaleMessages {
pending: string pending: string
registered: string registered: string
} }
purchase: {
card: string
continueTo: string
fiatRedirectHint: string
noCheckoutUrl: string
fiatFailed: string
loginRequiredTitle: string
loginRequiredFree: string
contact: {
title: string
name: string
namePlaceholder: string
nameRequired: string
email: string
emailHint: string
emailInvalid: string
confirmEmail: string
emailMismatch: string
}
}
checkout: {
title: string
paidTitle: string
processing: string
stillProcessing: string
stillProcessingNoEmail: string
emailedTo: string
cancelled: string
viewTicket: string
viewMyTickets: string
retry: string
ticketN: string
close: string
}
ticket: {
title: string
loading: string
notFound: string
event: string
holder: string
paid: string
pending: string
registered: string
showAtDoor: string
backToEvent: string
}
nav: { nav: {
feed: string feed: string
calendar: string calendar: 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

@ -41,7 +41,15 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue'
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue' import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { CreditCard } from 'lucide-vue-next'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
@ -124,7 +132,11 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
allow_fiat: z.boolean().default(false), // Rails buyers may use. `allow_fiat` on the wire is derived from
// this list (fiat ∈ list) so older extensions / clients keep working.
payment_methods: z
.array(z.enum(['lightning', 'fiat']))
.min(1, 'Enable at least one payment method'),
fiat_currency: z.string().default("USD"), fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
@ -152,7 +164,7 @@ const formSchema = toTypedSchema(
// mirror themselves into fiat_currency automatically. The events // mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably — accept both. // extension uses 'sat' and 'sats' interchangeably — accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats' const isSat = v.currency === 'sat' || v.currency === 'sats'
if (v.allow_fiat && isSat && !v.fiat_currency) { if (v.payment_methods.includes('fiat') && isSat && !v.fiat_currency) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['fiat_currency'], path: ['fiat_currency'],
@ -173,7 +185,7 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
allow_fiat: false, payment_methods: ['lightning'],
fiat_currency: 'USD', fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
@ -184,6 +196,42 @@ const form = useForm({
} }
}) })
/** Rails an existing event has on; legacy events (no list) derive
* from allow_fiat. */
function paymentMethodsOf(event: TicketedEvent): Array<'lightning' | 'fiat'> {
const explicit = (event.extra?.payment_methods ?? []).filter(
(m): m is 'lightning' | 'fiat' => m === 'lightning' || m === 'fiat',
)
if (explicit.length > 0) return explicit
return event.allow_fiat ? ['lightning', 'fiat'] : ['lightning']
}
const { hasAnyProvider, refresh: refreshFiatProviders } = useFiatProviders()
// Refresh once so the Card checkbox reflects providers the organizer may
// have just configured in LNbits.
refreshFiatProviders()
const PAYMENT_METHOD_OPTIONS = [
{ id: 'lightning' as const, label: 'Lightning', icon: Zap, hint: 'Pay with any Lightning wallet' },
{ id: 'fiat' as const, label: 'Card', icon: CreditCard, hint: 'Card or bank through your configured fiat provider' },
]
function isSatDenomination(d: string | undefined): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it — silently mirror so the backend payload stays consistent.
watch(
() => form.values.currency,
(d) => {
if (d && !isSatDenomination(d) && form.values.fiat_currency !== d) {
form.setFieldValue('fiat_currency', d)
}
},
{ immediate: true },
)
interface BannerImage extends UploadedImage { interface BannerImage extends UploadedImage {
isPrimary: boolean isPrimary: boolean
} }
@ -243,7 +291,7 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false, payment_methods: paymentMethodsOf(event),
fiat_currency: event.fiat_currency ?? 'USD', fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
@ -354,11 +402,12 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a true→false flip propagates on edit; // allow_fiat always sends (derived from the rail list) so a
// fiat_currency only sends when fiat is on (no point persisting a // true→false flip propagates on edit; fiat_currency only sends when
// rail-currency the backend won't use). // fiat is on (no point persisting a rail-currency the backend won't use).
eventData.allow_fiat = formValues.allow_fiat const acceptsFiat = formValues.payment_methods.includes('fiat')
if (formValues.allow_fiat && formValues.fiat_currency) { eventData.allow_fiat = acceptsFiat
if (acceptsFiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency eventData.fiat_currency = formValues.fiat_currency
} }
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
@ -371,6 +420,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
// survive the round-trip. // survive the round-trip.
eventData.extra = { eventData.extra = {
...(props.event?.extra ?? {}), ...(props.event?.extra ?? {}),
payment_methods: formValues.payment_methods,
email_notifications: formValues.email_notifications, email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications, nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject, notification_subject: formValues.notification_subject,
@ -660,26 +710,87 @@ const handleOpenChange = (open: boolean) => {
</div> </div>
<!-- ── Payment methods ──────────────────────────────────── --> <!-- ── Payment methods ──────────────────────────────────── -->
<div class="space-y-3"> <FormField name="payment_methods">
<FormItem class="space-y-3">
<div> <div>
<p class="text-sm font-medium">Payment methods</p> <FormLabel class="text-sm font-medium">Payment methods</FormLabel>
<p class="text-xs text-muted-foreground"> <FormDescription class="text-xs">
Lightning is always available. Enable fiat to also accept Pick the rails buyers can pay with. Turn Lightning off for a
card and bank payments through your configured provider. card-only sale.
</p> </FormDescription>
</div> </div>
<div class="flex items-center gap-2 text-sm text-muted-foreground"> <div class="space-y-2">
<Zap class="w-4 h-4" /> <FormField
<span>Lightning — always on</span> v-for="opt in PAYMENT_METHOD_OPTIONS"
</div> :key="opt.id"
<FiatToggleField v-slot="{ value, handleChange }"
allow-fiat-field="allow_fiat" type="checkbox"
fiat-currency-field="fiat_currency" :value="opt.id"
:denomination="form.values.currency ?? 'sat'" :unchecked-value="false"
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')" name="payment_methods"
>
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3">
<FormControl>
<TooltipProvider v-if="opt.id === 'fiat' && !hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Checkbox :model-value="false" disabled />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-xs">
Your LNbits user has no fiat provider configured. Open
LNbits → Account → Fiat providers and add Stripe, PayPal,
or Square to enable card payments.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
v-else
:model-value="Array.isArray(value) && value.includes(opt.id)"
:disabled="isLoading" :disabled="isLoading"
@update:model-value="handleChange"
/> />
</FormControl>
<div class="flex items-center gap-2 min-w-0">
<component :is="opt.icon" class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<FormLabel class="font-normal">{{ opt.label }}</FormLabel>
<p class="text-xs text-muted-foreground">{{ opt.hint }}</p>
</div> </div>
</div>
</FormItem>
</FormField>
</div>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="fiat_currency">
<FormItem
v-show="(form.values.payment_methods ?? []).includes('fiat') && isSatDenomination(form.values.currency)"
>
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="isLoading">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">Currency card buyers are charged in.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Ticket buyer notifications (collapsible). The backend <!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on sends email + NIP-04 Nostr DM confirmations on

View file

@ -1,13 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next' import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, { import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry, type PaymentMethod as PaymentMethodEntry,
@ -15,6 +28,9 @@ import PaymentMethodSelector, {
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue' import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders' import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion' import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
import { effectivePaymentMethods } from '../lib/paymentMethods'
import { frontendRoot } from '../lib/frontendRoot'
import { PENDING_CHECKOUT_KEY, type PendingCheckout } from '../lib/pendingCheckout'
interface Props { interface Props {
event: { event: {
@ -25,6 +41,9 @@ interface Props {
/** Whether the event accepts fiat payments. From v1.4.0+ */ /** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean allow_fiat?: boolean
fiat_currency?: string fiat_currency?: string
/** Rails the organizer enabled (`tickets_payment_methods`). Absent
* on legacy events — falls back to the allow_fiat rule. */
payment_methods?: string[]
} }
isOpen: boolean isOpen: boolean
} }
@ -35,6 +54,7 @@ interface Emits {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { t } = useI18n()
const { isAuthenticated, userDisplay, currentUser } = useAuth() const { isAuthenticated, userDisplay, currentUser } = useAuth()
const { const {
@ -45,7 +65,6 @@ const {
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
canPurchase,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
@ -54,6 +73,7 @@ const {
resetPaymentState, resetPaymentState,
cleanup, cleanup,
purchasedTicketIds, purchasedTicketIds,
ticketQRCodes,
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
@ -75,6 +95,11 @@ const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// and label the CTA "Get ticket" instead of "Proceed". // and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0) const isFree = computed(() => props.event.price_per_ticket <= 0)
// Guests may buy paid tickets (name + email identity); free claims stay
// login-gated so an anonymous script can't drain a free event's
// capacity (events#29).
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
async function copyInvoice() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { try {
@ -87,16 +112,66 @@ async function copyInvoice() {
} }
} }
// --- Contact (delivery email) form ---------------------------------
// Every ticket carries an email so the extension can deliver it (and
// so Stripe's checkout is prefilled + locked to the same address).
// Guests additionally need a name — that pair is their identity on
// the backend. Confirm-email guards against typos on the one field a
// guest cannot fix afterwards.
const contactSchema = computed(() =>
toTypedSchema(
z
.object({
name: isAuthenticated.value
? z.string().optional().default('')
: z.string().trim().min(1, t('events.purchase.contact.nameRequired')).max(100),
email: z.string().trim().email(t('events.purchase.contact.emailInvalid')),
confirmEmail: z.string().trim(),
})
.superRefine((v, ctx) => {
if (v.email.toLowerCase() !== v.confirmEmail.toLowerCase()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmEmail'],
message: t('events.purchase.contact.emailMismatch'),
})
}
}),
),
)
const form = useForm({
validationSchema: contactSchema,
initialValues: { name: '', email: '', confirmEmail: '' },
})
// The dialog stays mounted across opens — reseed on every open so a
// login/logout between purchases is reflected.
watch(
() => props.isOpen,
(open) => {
if (!open) return
form.resetForm({
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
})
},
{ immediate: true },
)
const { providers, providerMeta } = useFiatProviders() const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion() const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning') const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false) const isFiatPending = ref(false)
const fiatError = ref<string | null>(null) const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat)) const enabledRails = computed(() =>
effectivePaymentMethods({
allowFiat: props.event.allow_fiat,
paymentMethods: props.event.payment_methods,
}),
)
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
const isPriceInSats = computed( const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats', () => props.event.currency === 'sat' || props.event.currency === 'sats',
) )
@ -124,7 +199,9 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
} }
const paymentMethods = computed<PaymentMethodEntry[]>(() => { const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = { const entries: PaymentMethodEntry[] = []
if (enabledRails.value.includes('lightning')) {
entries.push({
id: 'lightning', id: 'lightning',
rail: 'lightning', rail: 'lightning',
label: 'Lightning', label: 'Lightning',
@ -134,115 +211,134 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
!isPriceInSats.value && lightningSats.value !isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats` ? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined, : undefined,
})
} }
if (!props.event.allow_fiat) return [lightning] if (canChooseFiat.value) {
if (providers.value.length > 0) { if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => { for (const id of providers.value) {
const meta = providerMeta(id) const meta = providerMeta(id)
return { entries.push({
id: `fiat:${id}`, id: `fiat:${id}`,
rail: 'fiat', rail: 'fiat',
provider: id, provider: id,
label: meta.label, label: meta.label,
icon: iconFor(meta.icon), icon: iconFor(meta.icon),
available: true, available: true,
}
}) })
return [lightning, ...fiatRails]
} }
} else {
// Degenerate fallback — allow_fiat is on but the buyer's session // Guests (and sessions without provider info) can't enumerate the
// can't enumerate the organizer's providers. Show a generic Card // organizer's providers. Show a generic Card button and let the
// button and let the backend pick a default at request time. // backend pick the organizer's default at request time.
return [ entries.push({
lightning,
{
id: 'fiat', id: 'fiat',
rail: 'fiat', rail: 'fiat',
label: 'Card', label: t('events.purchase.card'),
icon: CreditCard, icon: CreditCard,
available: true, available: true,
},
]
}) })
}
}
return entries
})
// Default rail: guests lean to card when it's on offer (no wallet to
// pay a bolt11 with); everyone else takes the first enabled rail.
watch(
[paymentMethods, () => props.isOpen, isAuthenticated],
([methods, open, authed]) => {
if (!open || methods.length === 0) return
const current = methods.find((m) => m.id === selectedMethodId.value)
if (current) return
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined
selectedMethodId.value = (preferred ?? methods[0]).id
},
{ immediate: true },
)
const selectedMethod = computed(() => const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0], paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
) )
async function handlePurchase() { const onSubmit = form.handleSubmit(async (contact) => {
if (!canPurchase.value) return await handlePurchase(contact)
fiatError.value = null })
async function handlePurchase(contact: { name?: string; email: string }) {
fiatError.value = null
const method = selectedMethod.value const method = selectedMethod.value
if (!method) return if (!method) return
// Lightning path: the composable just creates the invoice + starts const isGuest = !isAuthenticated.value
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here. // Lightning path: the composable creates the invoice + starts polling.
// The buyer pays from their LNbits wallet or an external one on the
// invoice screen (restaurant pattern), so no auto-pay here.
if (method.rail === 'lightning') { if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value }) await purchaseTicketForEvent(props.event.id, {
quantity: quantity.value,
contact: guestContact,
email: contact.email,
})
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
return return
} }
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the // Fiat path: POST with the chosen provider, remember what we're
// API directly with the chosen provider, then redirect the buyer to // waiting for, then hand the tab to the provider's checkout. Stripe
// the provider's checkout URL. Payment confirmation happens via // brings the buyer back to the event page with ?checkout=success&
// webhook on the backend and shows up next time the buyer reloads // tickets=… (or ?checkout=cancelled) — see EventDetailPage.
// MyTickets.
try { try {
isFiatPending.value = true isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || '' const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const userId = currentUser.value?.id const invoice = await ticketApi.requestTicket(props.event.id, {
if (!userId) { userId: isGuest ? undefined : currentUser.value?.id,
fiatError.value = 'Missing user id' name: guestContact?.name,
return email: contact.email,
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken, accessToken,
{ frontendUrl: frontendRoot(),
paymentMethod: 'fiat', paymentMethod: 'fiat',
fiatProvider: method.provider, fiatProvider: method.provider,
quantity: quantity.value, quantity: quantity.value,
}, })
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) { if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.' fiatError.value = t('events.purchase.noCheckoutUrl')
isFiatPending.value = false
return return
} }
fiatRedirectUrl.value = invoice.fiatPaymentRequest const pending: PendingCheckout = {
fiatProviderLabel.value = invoice.fiatProvider eventId: props.event.id,
? providerMeta(invoice.fiatProvider).label paymentHash: invoice.paymentHash,
: method.label ticketIds: invoice.ticketIds ?? [],
email: contact.email,
ts: Date.now(),
}
try {
localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(pending))
} catch {
// Storage unavailable (private mode) — the return URL still
// carries the ticket ids, so the flow survives without it.
}
window.location.assign(invoice.fiatPaymentRequest)
// Keep the spinner up until the navigation lands.
} catch (err) { } catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed' fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
} finally {
isFiatPending.value = false isFiatPending.value = false
} }
} }
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
selectedMethodId.value = 'lightning' selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null fiatError.value = null
isFiatPending.value = false
quantity.value = 1 quantity.value = 1
copiedInvoice.value = false copiedInvoice.value = false
} }
@ -274,25 +370,26 @@ onUnmounted(() => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<!-- Authentication Check --> <!-- Free tickets need an account (anti-abuse). Paid tickets don't. -->
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4"> <div v-if="guestNeedsLogin" class="py-4 text-center space-y-4">
<div class="flex justify-center"> <div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" /> <User class="w-12 h-12 text-muted-foreground" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-lg font-semibold">Login Required</h3> <h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Please log in to your account to purchase tickets using your wallet. {{ t('events.purchase.loginRequiredFree') }}
</p> </p>
</div> </div>
<Button @click="handleClose" variant="outline"> <div class="flex gap-2 justify-center">
Close <Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button>
</Button> <Button @click="handleClose" variant="outline">Close</Button>
</div>
</div> </div>
<!-- User Info and Purchase --> <!-- Contact + payment details, then submit -->
<div v-else-if="!paymentHash" class="py-4 space-y-4"> <form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit">
<div class="bg-muted/50 rounded-lg p-4 space-y-3"> <div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" /> <User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span> <span class="text-sm font-medium">Purchasing as:</span>
@ -309,8 +406,44 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Wallet Information --> <!-- Contact: who the ticket is for + where to send it -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3"> <div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Mail class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('events.purchase.contact.title') }}</span>
</div>
<FormField v-if="!isAuthenticated" v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.name') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" autocomplete="name" :placeholder="t('events.purchase.contact.namePlaceholder')" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.email') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormDescription class="text-xs">{{ t('events.purchase.contact.emailHint') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="confirmEmail">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.confirmEmail') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Wallet Information (logged-in Lightning buyers) -->
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" /> <Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span> <span class="text-sm font-medium">Wallet Status:</span>
@ -329,7 +462,7 @@ onUnmounted(() => {
<Badge v-else variant="secondary" class="text-xs">Empty</Badge> <Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div> </div>
</div> </div>
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600"> <div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary">
<Zap class="w-4 h-4" /> <Zap class="w-4 h-4" />
<span>Auto-payment available</span> <span>Auto-payment available</span>
</div> </div>
@ -347,7 +480,7 @@ onUnmounted(() => {
</div> </div>
<!-- Quantity selector — backend caps at 10. One invoice for <!-- Quantity selector — backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. --> the whole purchase, N ticket rows (one per attendee). -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span> <span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -392,18 +525,15 @@ onUnmounted(() => {
from="sat" from="sat"
:to="event.fiat_currency" :to="event.fiat_currency"
prefix="Equivalent ~" prefix="Equivalent ~"
suffix=" if paid in fiat" suffix=" if paid by card"
/> />
</div> </div>
</div> </div>
<!-- Payment method selector (only shown when fiat is enabled <!-- Payment method selector — one button per enabled rail /
on the event). Buttons surface one per configured fiat configured fiat provider. Hidden when only one rail is on
provider so "Stripe" / "PayPal" / "Square" stand alongside (nothing to choose) and for free tickets. -->
Lightning rather than collapsing into a single "Fiat" <div v-if="paymentMethods.length > 1 && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. -->
<div v-if="canChooseFiat && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div> <div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails. Both methods charge the same amount via different rails.
@ -421,26 +551,9 @@ onUnmounted(() => {
{{ error || fiatError }} {{ error || fiatError }}
</div> </div>
<!-- Fiat checkout panel — shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div>
<Button <Button
v-else type="submit"
@click="handlePurchase" :disabled="isLoading || isFiatPending || paymentMethods.length === 0 && !isFree"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
@ -450,14 +563,17 @@ onUnmounted(() => {
</template> </template>
<template v-else-if="selectedMethod?.rail === 'fiat'"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" /> <CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout {{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }}
</template> </template>
<template v-else> <template v-else>
<Zap class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }} {{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template> </template>
</Button> </Button>
</div> <p v-if="selectedMethod?.rail === 'fiat' && !isFree" class="text-xs text-muted-foreground text-center">
{{ t('events.purchase.fiatRedirectHint') }}
</p>
</form>
<!-- Lightning invoice — restaurant-style. Shows QR + amount, <!-- Lightning invoice — restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the with both pay paths visible at once: tap-to-pay from the
@ -468,8 +584,13 @@ onUnmounted(() => {
<div class="text-center space-y-1"> <div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3> <h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<template v-if="hasWalletWithBalance">
Scan with any Lightning wallet, or tap the button below to Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet. pay from your LNbits wallet.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p> </p>
</div> </div>
@ -553,21 +674,20 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Success state. QRs live in My Tickets — no need to <!-- Success state. Logged-in buyers get routed to My Tickets
pre-render them here; this view's job is to confirm the (QRs live there); guests have no My Tickets, so their QRs
purchase landed and route the buyer to where they actually render right here alongside the "emailed to" note. -->
interact with their tickets. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4"> <div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center"> <div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" /> <Ticket class="w-12 h-12 text-primary" />
</div> </div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600"> <h3 class="text-lg font-semibold text-primary">
{{ purchasedTicketIds.length > 1 {{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!` ? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }} : 'Ticket purchased!' }}
</h3> </h3>
<p class="text-sm text-muted-foreground"> <p v-if="isAuthenticated" class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1"> <span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets — Each attendee gets their own scannable QR in My Tickets —
hand them out independently for the door scan. hand them out independently for the door scan.
@ -576,10 +696,31 @@ onUnmounted(() => {
Your ticket is now in My Tickets. Your ticket is now in My Tickets.
</span> </span>
</p> </p>
<p v-if="form.values.email" class="text-sm text-muted-foreground">
{{ t('events.checkout.emailedTo', { email: form.values.email }) }}
</p>
</div>
<div v-if="!isAuthenticated" class="w-full space-y-3">
<div
v-for="(id, idx) in purchasedTicketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="purchasedTicketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="ticketQRCodes[id]" :src="ticketQRCodes[id]" :alt="`Ticket ${id}`" class="block h-40 w-40" />
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
</div> </div>
<div class="space-y-3 w-full"> <div class="space-y-3 w-full">
<Button @click="() => $router.push('/my-tickets')" class="w-full"> <Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets View My Tickets
</Button> </Button>
<Button variant="outline" @click="handleClose" class="w-full"> <Button variant="outline" @click="handleClose" class="w-full">

View file

@ -6,6 +6,22 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets' import { useOwnedTickets } from './useOwnedTickets'
import { frontendRoot } from '../lib/frontendRoot'
import { renderTicketQr } from '../lib/ticketQr'
/** Guest identity for a purchase without an LNbits account. */
export interface PurchaseContact {
name: string
email: string
}
export interface PurchaseTicketOptions {
quantity?: number
/** Guest buyer (no account). Required when not authenticated. */
contact?: PurchaseContact
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
email?: string
}
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
@ -64,11 +80,7 @@ export function useTicketPurchase() {
async function generateTicketQRCode(ticketId: string) { async function generateTicketQRCode(ticketId: string) {
try { try {
const ticketUrl = `ticket://${ticketId}` const dataUrl = await renderTicketQr(ticketId, { width: 160 })
const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, {
width: 128,
margin: 2
})
ticketQRCode.value = dataUrl ticketQRCode.value = dataUrl
return dataUrl return dataUrl
} catch (error) { } catch (error) {
@ -128,9 +140,13 @@ export function useTicketPurchase() {
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
eventId: string, eventId: string,
options: { quantity?: number } = {}, options: PurchaseTicketOptions = {},
) { ) {
if (!canPurchase.value || !currentUser.value) { // Guests are identified by name + email; logged-in buyers by user id.
// Either is enough for a paid ticket — free tickets stay login-gated
// in the dialog (events#29: no anonymous free issuance).
const isGuest = !canPurchase.value || !currentUser.value
if (isGuest && !options.contact) {
throw new Error('User must be authenticated to purchase tickets') throw new Error('User must be authenticated to purchase tickets')
} }
@ -150,12 +166,14 @@ export function useTicketPurchase() {
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || '' const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const invoice = await ticketApi.requestTicket( const invoice = await ticketApi.requestTicket(eventId, {
eventId, userId: isGuest ? undefined : currentUser.value!.id,
currentUser.value!.id, name: isGuest ? options.contact?.name : undefined,
accessToken, email: isGuest ? options.contact?.email : options.email,
{ quantity: options.quantity }, accessToken: isGuest ? undefined : accessToken,
) frontendUrl: frontendRoot(),
quantity: options.quantity,
})
// Free tickets (price 0 / 100%-off promo): the backend already // Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip // issued + marked them paid, so there's no invoice to settle. Skip

View file

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

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { frontendRoot } from './frontendRoot'
describe('frontendRoot', () => {
it('joins origin and base path without a trailing slash', () => {
expect(frontendRoot('https://demo.aiolabs.dev', '/events/')).toBe('https://demo.aiolabs.dev/events')
})
it('collapses a root base to the bare origin', () => {
expect(frontendRoot('http://localhost:5173', '/')).toBe('http://localhost:5173')
})
it('tolerates a base without leading slash', () => {
expect(frontendRoot('https://app.ariege.io', 'oyez/')).toBe('https://app.ariege.io/oyez')
})
})

View file

@ -0,0 +1,20 @@
/**
* This app's public root — origin + Vite base path, no trailing slash.
*
* Sent to the events extension as `frontend_url` so the backend can
* compose the Stripe success / cancel URLs and the emailed ticket link
* under the webapp (e.g. `https://demo.aiolabs.dev/events`,
* `https://app.ariege.io/oyez`) instead of the LNbits host. The
* extension appends the same path shapes the LNbits UI uses
* (`/events/{event_id}`, `/events/ticket/{ticket_id}`), which the events
* router serves under that base.
*
* Parameters exist for tests; callers use the defaults.
*/
export function frontendRoot(
origin: string = window.location.origin,
base: string = import.meta.env.BASE_URL ?? '/',
): string {
const path = base.startsWith('/') ? base : `/${base}`
return `${origin}${path}`.replace(/\/+$/, '')
}

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { effectivePaymentMethods } from './paymentMethods'
describe('effectivePaymentMethods', () => {
it('falls back to the legacy allowFiat rule when no list is published', () => {
expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat'])
expect(effectivePaymentMethods(undefined)).toEqual(['lightning'])
})
it('honours an explicit list, including fiat-only events', () => {
expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] }))
.toEqual(['lightning', 'fiat'])
})
it('drops unknown rails and falls back when nothing known remains', () => {
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat'])
})
})

View file

@ -0,0 +1,22 @@
export type PaymentRailId = 'lightning' | 'fiat'
const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
/**
* Rails a buyer may use for an event.
*
* Mirrors `effective_payment_methods()` in the events extension: an
* explicit `paymentMethods` list wins; otherwise the legacy rule applies
* — Lightning always, fiat only when the organizer set `allowFiat`.
* Unknown rail ids are dropped so a future backend value can't render
* a button the dialog has no handler for.
*/
export function effectivePaymentMethods(
info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined,
): PaymentRailId[] {
const explicit = (info?.paymentMethods ?? []).filter(
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
)
if (explicit.length > 0) return explicit
return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning']
}

View file

@ -0,0 +1,38 @@
/**
* What the buyer is waiting on while the tab is away at the fiat
* provider's checkout. Written right before the redirect; read back by
* EventDetailPage when Stripe returns with `?checkout=success` (the
* URL carries the ticket ids too — this record is the belt-and-braces
* copy that also remembers the delivery email for the confirmation
* screen). Cleared once the return dialog sees the tickets paid, or on
* `?checkout=cancelled`.
*/
export const PENDING_CHECKOUT_KEY = 'events:pending-checkout'
export interface PendingCheckout {
eventId: string
paymentHash: string
ticketIds: string[]
email?: string
ts: number
}
export function readPendingCheckout(): PendingCheckout | null {
try {
const raw = localStorage.getItem(PENDING_CHECKOUT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingCheckout
if (!parsed || typeof parsed.eventId !== 'string') return null
return parsed
} catch {
return null
}
}
export function clearPendingCheckout(): void {
try {
localStorage.removeItem(PENDING_CHECKOUT_KEY)
} catch {
// ignore
}
}

View file

@ -0,0 +1,61 @@
import brandLogo from '@brand/logo.png'
/**
* Render the scannable `ticket://<id>` QR every ticket surface uses
* (purchase success, Stripe return dialog, ticket page, My Tickets).
*
* Error-correction level H (30 % recoverable) leaves room for the brand
* logo the events extension also composites into its emailed PNG, so
* the in-app and emailed images look alike. The overlay is best-effort:
* if canvas or the logo asset is unavailable the plain QR is returned.
*/
export async function renderTicketQr(
ticketId: string,
options: { width?: number; logo?: boolean } = {},
): Promise<string> {
const width = options.width ?? 200
const qrcode = await import('qrcode')
const plain = await qrcode.toDataURL(`ticket://${ticketId}`, {
width,
margin: 2,
errorCorrectionLevel: 'H',
color: { dark: '#000000', light: '#FFFFFF' },
})
if (options.logo === false) return plain
try {
return await overlayLogo(plain, width)
} catch {
return plain
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Could not load ${src}`))
img.src = src
})
}
async function overlayLogo(qrDataUrl: string, width: number): Promise<string> {
if (typeof document === 'undefined') return qrDataUrl
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = width
const ctx = canvas.getContext('2d')
if (!ctx) return qrDataUrl
const [qr, logo] = await Promise.all([loadImage(qrDataUrl), loadImage(brandLogo)])
ctx.drawImage(qr, 0, 0, width, width)
// Logo ≤ 20 % of the QR width on a white pad ≈ 26 % — well inside the
// 30 % damage budget of level H, matching the backend's PNG endpoint.
const logoSize = Math.round(width * 0.2)
const padSize = Math.round(width * 0.26)
const center = width / 2
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(center - padSize / 2, center - padSize / 2, padSize, padSize)
ctx.drawImage(logo, center - logoSize / 2, center - logoSize / 2, logoSize, logoSize)
return canvas.toDataURL('image/png')
}

View file

@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TicketApiService } from './TicketApiService'
const BASE = 'https://lnbits.test'
function okJson(body: unknown) {
return { ok: true, json: async () => body } as Response
}
describe('TicketApiService.requestTicket', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
})
afterEach(() => vi.unstubAllGlobals())
function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
}
it('sends name + email and no user_id / Authorization for guests', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] }))
const res = await api.requestTicket('evt', {
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events',
paymentMethod: 'fiat', quantity: 2,
})
const { url, body, headers } = lastCall()
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`)
expect(body).toEqual({
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
payment_method: 'fiat', quantity: 2,
})
expect(body.user_id).toBeUndefined()
expect(headers.Authorization).toBeUndefined()
expect(headers['X-API-KEY']).toBe('inst-key')
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] })
})
it('sends user_id + email with a Bearer token for logged-in buyers', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
await api.requestTicket('evt', {
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173',
})
const { body, headers } = lastCall()
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' })
expect(headers.Authorization).toBe('Bearer tok')
})
it('surfaces the backend detail string as the error message', async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response)
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' }))
.rejects.toThrow('Payment method not enabled for this event.')
})
})
describe('TicketApiService.getTicket / resendTicketEmail', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
})
afterEach(() => vi.unstubAllGlobals())
it('maps the anonymous PublicTicket shape', async () => {
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' }))
const t = await api.getTicket('tid')
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`)
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' })
})
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => {
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} }
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } }))
const r = await api.resendTicketEmail('tid', 'admin')
expect(r.ticket.id).toBe('tid')
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' })
expect(r.nostr.attempted).toBe(false)
fetchMock.mockResolvedValueOnce(okJson(ticket))
const legacy = await api.resendTicketEmail('tid', 'admin')
expect(legacy.ticket.id).toBe('tid')
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
})
})

View file

@ -3,8 +3,10 @@ import type {
EventTicketExtra, EventTicketExtra,
CreateTicketRequest, CreateTicketRequest,
PaymentMethod, PaymentMethod,
PublicTicket,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketResendResult,
TicketedEvent, TicketedEvent,
CreateEventRequest, CreateEventRequest,
} from '../types/ticket' } from '../types/ticket'
@ -14,6 +16,27 @@ export interface TicketApiConfig {
apiKey: string apiKey: string
} }
/** Options for `TicketApiService.requestTicket`. */
export interface RequestTicketOptions {
/** LNbits user id — logged-in buyers. */
userId?: string
/** Guest buyer name (required together with `email` when no `userId`). */
name?: string
/** Delivery email — required for guests, optional for logged-in buyers. */
email?: string
/** Bearer token; forwarded only when present. */
accessToken?: string
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
frontendUrl?: string
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
}
/** /**
* Database-backed ticketing API service. * Database-backed ticketing API service.
* Talks to the LNbits events extension for ticket inventory, * Talks to the LNbits events extension for ticket inventory,
@ -57,29 +80,27 @@ export class TicketApiService {
* = follow-the-URL string from the configured fiat provider). The * = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator. * `isFiat` flag is the discriminator.
* *
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into * Identity: pass `userId` for a logged-in buyer (plus `email` so the
* the fiat path (requires the event to have `allow_fiat=true`). * ticket can be delivered by mail), or `name` + `email` for a guest.
* `fiatProvider` is optional — backend picks the user's configured * The endpoint itself is anonymous; `accessToken` is only forwarded
* default when omitted. * when present.
* *
* Additional ticket metadata (promo code, refund address, nostr * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* identifier for DM delivery) can be supplied via `options`. * the fiat path (the event must have that rail enabled — the backend
* 400s otherwise). `fiatProvider` is optional — backend picks the
* organizer's configured default when omitted. `frontendUrl` is this
* app's root (see `lib/frontendRoot.ts`) and drives the Stripe
* return URLs + emailed ticket link.
*/ */
async requestTicket( async requestTicket(
eventId: string, eventId: string,
userId: string, options: RequestTicketOptions = {},
accessToken: string,
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId } const body: CreateTicketRequest = {}
if (options.userId) body.user_id = options.userId
if (options.name) body.name = options.name
if (options.email) body.email = options.email
if (options.frontendUrl) body.frontend_url = options.frontendUrl
if (options.paymentMethod) body.payment_method = options.paymentMethod if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode if (options.promoCode) body.promo_code = options.promoCode
@ -87,14 +108,14 @@ export class TicketApiService {
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(body), body: JSON.stringify(body),
} }
) )
@ -112,6 +133,33 @@ export class TicketApiService {
} }
} }
/**
* Anonymous read of one ticket — paid / registered state only, no
* PII. Used by the Stripe return dialog to poll settlement and by
* the public ticket page the emailed link opens.
*/
async getTicket(ticketId: string): Promise<PublicTicket> {
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' })
return {
eventId: t.event,
name: t.name ?? null,
registered: Boolean(t.registered),
paid: Boolean(t.paid),
time: t.time,
regTimestamp: t.reg_timestamp,
}
}
/**
* Anonymous public event detail (`PublicEvent`). Note the backend
* answers 410 for sold-out / closed events — callers that only need
* a display name should try the Nostr store first and treat a
* failure here as non-fatal.
*/
async getPublicEvent(eventId: string): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' })
}
/** /**
* Check whether a ticket payment has been confirmed. * Check whether a ticket payment has been confirmed.
*/ */
@ -226,22 +274,30 @@ export class TicketApiService {
/** /**
* Resend the ticket confirmation email for a paid ticket. Requires * Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated * the event's wallet admin key (organizer-only). Returns the updated
* Ticket with the `email_notification_sent` flag refreshed. * Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8;
* older extensions returned the bare Ticket, which is tolerated).
* *
* Endpoint added upstream in v1.6.1 (PR #51). * Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8.
*/ */
async resendTicketEmail( async resendTicketEmail(
ticketId: string, ticketId: string,
adminKey: string, adminKey: string,
): Promise<EventTicket> { ): Promise<TicketResendResult> {
const t = await this.request( const r = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`, `/events/api/v1/tickets/${ticketId}/resend-email`,
{ {
method: 'POST', method: 'POST',
headers: { 'X-API-KEY': adminKey }, headers: { 'X-API-KEY': adminKey },
} }
) )
const t = r.ticket ?? r
const delivery = (d: any) => ({
attempted: Boolean(d?.attempted),
sent: Boolean(d?.sent),
error: d?.error ?? null,
})
return { return {
ticket: {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -253,6 +309,9 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
},
email: delivery(r.email),
nostr: delivery(r.nostr),
} }
} }

View file

@ -84,6 +84,8 @@ export interface EventTicketInfo {
allowFiat: boolean allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string fiatCurrency?: string
/** Rails the organizer enabled (see TicketTags.paymentMethods). */
paymentMethods?: string[]
} }
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined { function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
@ -98,6 +100,7 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined, total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
allowFiat: ticket.allowFiat, allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency, fiatCurrency: ticket.fiatCurrency,
paymentMethods: ticket.paymentMethods,
} }
} }

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { parseCalendarTimeEvent, NIP52_KINDS } from './nip52'
import type { NostrEvent } from 'nostr-tools'
function makeEvent(extraTags: string[][]): NostrEvent {
return {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
id: 'id',
pubkey: 'pk',
created_at: 1_700_000_000,
content: '',
sig: '',
tags: [
['d', 'evt1'],
['title', 'Test'],
['start', '1700000000'],
['tickets_currency', 'EUR'],
['tickets_price', '20'],
...extraTags,
],
} as NostrEvent
}
describe('parseTicketTags — tickets_payment_methods', () => {
it('parses the comma list into paymentMethods', () => {
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_payment_methods', 'lightning, fiat']]))
expect(parsed?.ticket?.paymentMethods).toEqual(['lightning', 'fiat'])
})
it('leaves paymentMethods undefined when the tag is absent (legacy events)', () => {
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_allow_fiat', 'true']]))
expect(parsed?.ticket?.allowFiat).toBe(true)
expect(parsed?.ticket?.paymentMethods).toBeUndefined()
})
})

View file

@ -36,6 +36,10 @@ export interface TicketTags {
allowFiat: boolean allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string fiatCurrency?: string
/** Rails the organizer enabled, from `tickets_payment_methods`
* ("lightning,fiat"). Undefined on events published before the
* tag existed — callers fall back to the allowFiat rule. */
paymentMethods?: string[]
} }
/** /**
@ -137,6 +141,7 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
const soldStr = getTagValue(tags, 'tickets_sold') const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price') const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat') const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods')
return { return {
available: availableStr != null ? Number(availableStr) : undefined, available: availableStr != null ? Number(availableStr) : undefined,
@ -145,6 +150,9 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
currency, currency,
allowFiat: allowFiatStr === 'true', allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'), fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
paymentMethods: paymentMethodsStr != null
? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean)
: undefined,
} }
} }

View file

@ -26,6 +26,10 @@ export interface EventExtra {
nostr_notifications: boolean nostr_notifications: boolean
notification_subject: string notification_subject: string
notification_body: string notification_body: string
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */
payment_methods?: string[]
} }
export interface EventTicketExtra { export interface EventTicketExtra {
@ -165,17 +169,22 @@ export interface CreateEventRequest {
} }
/** /**
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the * Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in
* `name`+`email` pair is required (backend root_validator enforces * buyer, optionally with `email` for ticket delivery) OR the
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM * `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer
* delivery when the event has nostr_notifications enabled. The * into Nostr DM delivery when the event has nostr_notifications
* `payment_method` + `fiat_provider` pair selects between Lightning * enabled. The `payment_method` + `fiat_provider` pair selects between
* and fiat checkout. * Lightning and fiat checkout. `frontend_url` is this app's root so the
* backend can build the Stripe return URLs and the emailed ticket link
* under the webapp instead of the LNbits host.
*/ */
export interface CreateTicketRequest { export interface CreateTicketRequest {
name?: string name?: string
email?: string email?: string
user_id?: string user_id?: string
/** App root (origin + base path, no trailing slash). Origin must be
* on the LNbits instance's CORS allowlist or the backend 400s. */
frontend_url?: string
promo_code?: string promo_code?: string
refund_address?: string refund_address?: string
nostr_identifier?: string nostr_identifier?: string
@ -184,3 +193,33 @@ export interface CreateTicketRequest {
/** Number of tickets on this invoice (backend bounds 1..10). */ /** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number quantity?: number
} }
/**
* Anonymous view of a ticket from `GET /tickets/{ticket_id}`. The
* backend deliberately omits id / email / user_id; the id is whatever
* the caller asked for.
*/
export interface PublicTicket {
eventId: string
name: string | null
registered: boolean
paid: boolean
time: string
regTimestamp: string
}
export interface NotificationDeliveryResult {
attempted: boolean
sent: boolean
error: string | null
}
/**
* Structured response of `POST /tickets/{ticket_id}/resend-email`
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
*/
export interface TicketResendResult {
ticket: EventTicket
email: NotificationDeliveryResult
nostr: NotificationDeliveryResult
}

View file

@ -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 -->

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { renderTicketQr } from '../lib/ticketQr'
import { RouterLink, onBeforeRouteLeave } from 'vue-router' import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { useUserTickets } from '../composables/useUserTickets' import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents' import { useEvents } from '../composables/useEvents'
@ -134,13 +135,7 @@ async function generateQRCode(ticketId: string) {
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId] if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
try { try {
const qrcode = await import('qrcode') const dataUrl = await renderTicketQr(ticketId, { width: 200 })
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }
})
qrCodes.value[ticketId] = dataUrl qrCodes.value[ticketId] = dataUrl
return dataUrl return dataUrl
} catch (error) { } catch (error) {

View 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>