Merge pull request 'feat(events): guest checkout with email delivery, Stripe return flow, public ticket page' (#144) from feat/events-guest-checkout into dev
Reviewed-on: #144
This commit is contained in:
commit
411ac0c93c
26 changed files with 1576 additions and 230 deletions
76
docs/02-modules/events-module/guest-checkout.md
Normal file
76
docs/02-modules/events-module/guest-checkout.md
Normal 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: {...} }`).
|
||||||
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal file
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Ticket, Loader2, Clock } from 'lucide-vue-next'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
import { renderTicketQr } from '../lib/ticketQr'
|
||||||
|
import { clearPendingCheckout } from '../lib/pendingCheckout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the buyer lands back on the event page from the fiat
|
||||||
|
* provider with `?checkout=success&tickets=…`. Polls the anonymous
|
||||||
|
* ticket endpoint until every row is paid (Stripe waits for our
|
||||||
|
* webhook before redirecting, so the first poll usually already
|
||||||
|
* says paid), then renders one scannable QR per ticket.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
eventId: string
|
||||||
|
ticketIds: string[]
|
||||||
|
email?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:isOpen', value: boolean): void }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
|
||||||
|
const POLL_MS = 2000
|
||||||
|
const TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
type Phase = 'checking' | 'paid' | 'timeout'
|
||||||
|
const phase = ref<Phase>('checking')
|
||||||
|
const qrCodes = ref<Record<string, string>>({})
|
||||||
|
let timer: number | null = null
|
||||||
|
let startedAt = 0
|
||||||
|
|
||||||
|
const paidCount = ref(0)
|
||||||
|
|
||||||
|
async function checkOnce(): Promise<boolean> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
props.ticketIds.map((id) => ticketApi.getTicket(id).then((t) => t.paid).catch(() => false)),
|
||||||
|
)
|
||||||
|
paidCount.value = results.filter(Boolean).length
|
||||||
|
return results.length > 0 && results.every(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPaid() {
|
||||||
|
phase.value = 'paid'
|
||||||
|
stop()
|
||||||
|
clearPendingCheckout()
|
||||||
|
if (isAuthenticated.value) void refreshOwnedTickets()
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const id of props.ticketIds) {
|
||||||
|
try {
|
||||||
|
map[id] = await renderTicketQr(id, { width: 200 })
|
||||||
|
} catch {
|
||||||
|
// leave the slot empty; the ticket page link still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qrCodes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (phase.value !== 'checking') return
|
||||||
|
try {
|
||||||
|
if (await checkOnce()) {
|
||||||
|
await onPaid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient network error — keep polling until the timeout
|
||||||
|
}
|
||||||
|
if (Date.now() - startedAt > TIMEOUT_MS) {
|
||||||
|
phase.value = 'timeout'
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
stop()
|
||||||
|
phase.value = 'checking'
|
||||||
|
qrCodes.value = {}
|
||||||
|
startedAt = Date.now()
|
||||||
|
void tick()
|
||||||
|
timer = window.setInterval(() => void tick(), POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.isOpen, props.ticketIds.join(',')] as const,
|
||||||
|
([open]) => {
|
||||||
|
if (open && props.ticketIds.length > 0) start()
|
||||||
|
else stop()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(stop)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
stop()
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
phase.value === 'paid' ? t('events.checkout.paidTitle') : t('events.checkout.title'),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="close">
|
||||||
|
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<Ticket class="w-5 h-5" />
|
||||||
|
{{ title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription v-if="phase === 'paid' && email">
|
||||||
|
{{ t('events.checkout.emailedTo', { email }) }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div v-if="phase === 'checking'" class="py-8 flex flex-col items-center gap-3 text-center">
|
||||||
|
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||||
|
<p class="text-sm text-muted-foreground">{{ t('events.checkout.processing') }}</p>
|
||||||
|
<p v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
|
||||||
|
{{ paidCount }} / {{ ticketIds.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="phase === 'timeout'" class="py-6 flex flex-col items-center gap-3 text-center">
|
||||||
|
<Clock class="w-10 h-10 text-muted-foreground" />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ email
|
||||||
|
? t('events.checkout.stillProcessing', { email })
|
||||||
|
: t('events.checkout.stillProcessingNoEmail') }}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" @click="start">{{ t('events.checkout.retry') }}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="py-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(id, idx) in ticketIds"
|
||||||
|
:key="id"
|
||||||
|
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<span v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('events.checkout.ticketN', { n: idx + 1, total: ticketIds.length }) }}
|
||||||
|
</span>
|
||||||
|
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img v-if="qrCodes[id]" :src="qrCodes[id]" :alt="`Ticket ${id}`" class="block h-48 w-48" />
|
||||||
|
<div v-else class="h-48 w-48 flex items-center justify-center">
|
||||||
|
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
|
||||||
|
{{ t('events.checkout.viewTicket') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground text-center">{{ t('events.ticket.showAtDoor') }}</p>
|
||||||
|
<div class="space-y-2 pt-1">
|
||||||
|
<Button v-if="isAuthenticated" class="w-full" @click="() => { close(); $router.push('/my-tickets') }">
|
||||||
|
{{ t('events.checkout.viewMyTickets') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" class="w-full" @click="close">{{ t('events.checkout.close') }}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
@ -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">
|
||||||
<div>
|
<FormItem class="space-y-3">
|
||||||
<p class="text-sm font-medium">Payment methods</p>
|
<div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<FormLabel class="text-sm font-medium">Payment methods</FormLabel>
|
||||||
Lightning is always available. Enable fiat to also accept
|
<FormDescription class="text-xs">
|
||||||
card and bank payments through your configured provider.
|
Pick the rails buyers can pay with. Turn Lightning off for a
|
||||||
</p>
|
card-only sale.
|
||||||
</div>
|
</FormDescription>
|
||||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
</div>
|
||||||
<Zap class="w-4 h-4" />
|
<div class="space-y-2">
|
||||||
<span>Lightning — always on</span>
|
<FormField
|
||||||
</div>
|
v-for="opt in PAYMENT_METHOD_OPTIONS"
|
||||||
<FiatToggleField
|
:key="opt.id"
|
||||||
allow-fiat-field="allow_fiat"
|
v-slot="{ value, handleChange }"
|
||||||
fiat-currency-field="fiat_currency"
|
type="checkbox"
|
||||||
:denomination="form.values.currency ?? 'sat'"
|
:value="opt.id"
|
||||||
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
:unchecked-value="false"
|
||||||
:disabled="isLoading"
|
name="payment_methods"
|
||||||
/>
|
>
|
||||||
</div>
|
<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"
|
||||||
|
@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>
|
||||||
|
</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
|
||||||
|
|
|
||||||
|
|
@ -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,125 +199,146 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
||||||
const lightning: PaymentMethodEntry = {
|
const entries: PaymentMethodEntry[] = []
|
||||||
id: 'lightning',
|
if (enabledRails.value.includes('lightning')) {
|
||||||
rail: 'lightning',
|
entries.push({
|
||||||
label: 'Lightning',
|
id: 'lightning',
|
||||||
icon: Zap,
|
rail: 'lightning',
|
||||||
available: true,
|
label: 'Lightning',
|
||||||
badge:
|
icon: Zap,
|
||||||
!isPriceInSats.value && lightningSats.value
|
|
||||||
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
if (!props.event.allow_fiat) return [lightning]
|
|
||||||
|
|
||||||
if (providers.value.length > 0) {
|
|
||||||
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
|
|
||||||
const meta = providerMeta(id)
|
|
||||||
return {
|
|
||||||
id: `fiat:${id}`,
|
|
||||||
rail: 'fiat',
|
|
||||||
provider: id,
|
|
||||||
label: meta.label,
|
|
||||||
icon: iconFor(meta.icon),
|
|
||||||
available: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return [lightning, ...fiatRails]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Degenerate fallback — allow_fiat is on but the buyer's session
|
|
||||||
// can't enumerate the organizer's providers. Show a generic Card
|
|
||||||
// button and let the backend pick a default at request time.
|
|
||||||
return [
|
|
||||||
lightning,
|
|
||||||
{
|
|
||||||
id: 'fiat',
|
|
||||||
rail: 'fiat',
|
|
||||||
label: 'Card',
|
|
||||||
icon: CreditCard,
|
|
||||||
available: true,
|
available: true,
|
||||||
},
|
badge:
|
||||||
]
|
!isPriceInSats.value && lightningSats.value
|
||||||
|
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (canChooseFiat.value) {
|
||||||
|
if (providers.value.length > 0) {
|
||||||
|
for (const id of providers.value) {
|
||||||
|
const meta = providerMeta(id)
|
||||||
|
entries.push({
|
||||||
|
id: `fiat:${id}`,
|
||||||
|
rail: 'fiat',
|
||||||
|
provider: id,
|
||||||
|
label: meta.label,
|
||||||
|
icon: iconFor(meta.icon),
|
||||||
|
available: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Guests (and sessions without provider info) can't enumerate the
|
||||||
|
// organizer's providers. Show a generic Card button and let the
|
||||||
|
// backend pick the organizer's default at request time.
|
||||||
|
entries.push({
|
||||||
|
id: 'fiat',
|
||||||
|
rail: 'fiat',
|
||||||
|
label: t('events.purchase.card'),
|
||||||
|
icon: CreditCard,
|
||||||
|
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">
|
||||||
Scan with any Lightning wallet, or tap the button below to
|
<template v-if="hasWalletWithBalance">
|
||||||
pay from your LNbits wallet.
|
Scan with any Lightning wallet, or tap the button below to
|
||||||
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
16
src/modules/events/lib/frontendRoot.spec.ts
Normal file
16
src/modules/events/lib/frontendRoot.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
20
src/modules/events/lib/frontendRoot.ts
Normal file
20
src/modules/events/lib/frontendRoot.ts
Normal 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(/\/+$/, '')
|
||||||
|
}
|
||||||
21
src/modules/events/lib/paymentMethods.spec.ts
Normal file
21
src/modules/events/lib/paymentMethods.spec.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
22
src/modules/events/lib/paymentMethods.ts
Normal file
22
src/modules/events/lib/paymentMethods.ts
Normal 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']
|
||||||
|
}
|
||||||
38
src/modules/events/lib/pendingCheckout.ts
Normal file
38
src/modules/events/lib/pendingCheckout.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/modules/events/lib/ticketQr.ts
Normal file
61
src/modules/events/lib/ticketQr.ts
Normal 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')
|
||||||
|
}
|
||||||
92
src/modules/events/services/TicketApiService.spec.ts
Normal file
92
src/modules/events/services/TicketApiService.spec.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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,33 +274,44 @@ 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 {
|
||||||
id: t.id,
|
ticket: {
|
||||||
wallet: t.wallet,
|
id: t.id,
|
||||||
eventId: t.event,
|
wallet: t.wallet,
|
||||||
name: t.name,
|
eventId: t.event,
|
||||||
email: t.email,
|
name: t.name,
|
||||||
userId: t.user_id,
|
email: t.email,
|
||||||
registered: t.registered,
|
userId: t.user_id,
|
||||||
paid: t.paid,
|
registered: t.registered,
|
||||||
time: t.time,
|
paid: t.paid,
|
||||||
regTimestamp: t.reg_timestamp,
|
time: t.time,
|
||||||
extra: t.extra as EventTicketExtra | undefined,
|
regTimestamp: t.reg_timestamp,
|
||||||
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
|
},
|
||||||
|
email: delivery(r.email),
|
||||||
|
nostr: delivery(r.nostr),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
src/modules/events/types/nip52.spec.ts
Normal file
35
src/modules/events/types/nip52.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
155
src/modules/events/views/TicketPage.vue
Normal file
155
src/modules/events/views/TicketPage.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ArrowLeft, Ticket, CheckCircle2, Clock, CreditCard, Loader2, AlertCircle } from 'lucide-vue-next'
|
||||||
|
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
import type { EventsNostrService } from '../services/EventsNostrService'
|
||||||
|
import { useEventsStore } from '../stores/events'
|
||||||
|
import { renderTicketQr } from '../lib/ticketQr'
|
||||||
|
import type { PublicTicket } from '../types/ticket'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public ticket page — where the emailed link lands
|
||||||
|
* (`<frontend_url>/events/ticket/{ticket_id}`). Anonymous: the ticket
|
||||||
|
* id in the URL is the door credential, the same token the QR carries,
|
||||||
|
* exactly like the LNbits UI's own /events/ticket/{id} page.
|
||||||
|
*/
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const ticketId = route.params.ticketId as string
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
const store = useEventsStore()
|
||||||
|
|
||||||
|
const ticket = ref<PublicTicket | null>(null)
|
||||||
|
const eventName = ref<string | null>(null)
|
||||||
|
const qr = ref<string | null>(null)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const notFound = ref(false)
|
||||||
|
|
||||||
|
const status = computed(() => {
|
||||||
|
if (!ticket.value) return null
|
||||||
|
if (!ticket.value.paid) return { label: t('events.ticket.pending'), icon: Clock, variant: 'secondary' as const }
|
||||||
|
if (ticket.value.registered) return { label: t('events.ticket.registered'), icon: CheckCircle2, variant: 'default' as const }
|
||||||
|
return { label: t('events.ticket.paid'), icon: CreditCard, variant: 'default' as const }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event name resolution, cheapest first: the Nostr store (d-tag ==
|
||||||
|
// LNbits event id), a scoped relay query, then the public REST event
|
||||||
|
// (which 410s for sold-out / closed events — non-fatal here).
|
||||||
|
async function resolveEventName(eventId: string) {
|
||||||
|
const cached = store.getEventById(eventId)
|
||||||
|
if (cached) {
|
||||||
|
eventName.value = cached.title
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nostr = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
|
if (nostr) {
|
||||||
|
try {
|
||||||
|
const results = await nostr.queryCalendarEvents({ dTags: [eventId] })
|
||||||
|
store.upsertEvents(results)
|
||||||
|
const hit = store.getEventById(eventId)
|
||||||
|
if (hit) {
|
||||||
|
eventName.value = hit.title
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pub = await ticketApi.getPublicEvent(eventId)
|
||||||
|
eventName.value = pub.name
|
||||||
|
} catch {
|
||||||
|
eventName.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
ticket.value = await ticketApi.getTicket(ticketId)
|
||||||
|
} catch {
|
||||||
|
notFound.value = true
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading.value = false
|
||||||
|
void resolveEventName(ticket.value.eventId)
|
||||||
|
try {
|
||||||
|
qr.value = await renderTicketQr(ticketId, { width: 240 })
|
||||||
|
} catch {
|
||||||
|
qr.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToEvent() {
|
||||||
|
if (ticket.value) router.push(`/events/${ticket.value.eventId}`)
|
||||||
|
else router.push('/events')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto py-6 px-4 max-w-md">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<Button variant="ghost" size="sm" class="gap-1.5" @click="goToEvent">
|
||||||
|
<ArrowLeft class="w-4 h-4" />
|
||||||
|
{{ t('events.ticket.backToEvent') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2 class="w-8 h-8 animate-spin" />
|
||||||
|
<span class="text-sm">{{ t('events.ticket.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="notFound" class="py-16 flex flex-col items-center gap-3 text-center">
|
||||||
|
<AlertCircle class="w-10 h-10 text-destructive" />
|
||||||
|
<p class="text-sm text-muted-foreground">{{ t('events.ticket.notFound') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ticket" class="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Ticket class="w-5 h-5 shrink-0" />
|
||||||
|
{{ t('events.ticket.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground truncate">
|
||||||
|
{{ eventName ?? t('events.ticket.event') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge v-if="status" :variant="status.variant" class="gap-1 shrink-0">
|
||||||
|
<component :is="status.icon" class="w-3.5 h-3.5" />
|
||||||
|
{{ status.label }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img v-if="qr" :src="qr" :alt="`Ticket ${ticketId}`" class="block h-60 w-60" />
|
||||||
|
<div v-else class="h-60 w-60 flex items-center justify-center">
|
||||||
|
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
<div v-if="ticket.name" class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">{{ t('events.ticket.holder') }}</span>
|
||||||
|
<span class="font-medium truncate">{{ ticket.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">ID</span>
|
||||||
|
<span class="font-mono text-xs">{{ ticketId }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
|
{{ ticket.paid ? t('events.ticket.showAtDoor') : t('events.ticket.pending') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue