diff --git a/docs/02-modules/events-module/guest-checkout.md b/docs/02-modules/events-module/guest-checkout.md new file mode 100644 index 0000000..25c31f7 --- /dev/null +++ b/docs/02-modules/events-module/guest-checkout.md @@ -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 ` | +| 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 | `/events/{event_id}?checkout=success&tickets=` | +| cancel | `/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://` 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: {...} }`). diff --git a/docs/02-modules/index.md b/docs/02-modules/index.md index 8b0fda2..a91b2bb 100644 --- a/docs/02-modules/index.md +++ b/docs/02-modules/index.md @@ -182,6 +182,13 @@ interface ModulePlugin { **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 ### **Creating a New Module** diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index dbb408c..19becc3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -129,6 +129,52 @@ const messages: LocaleMessages = { pending: 'Pending', 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: { feed: 'Home', calendar: 'Calendar', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9ca8958..7bb588b 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -129,6 +129,52 @@ const messages: LocaleMessages = { pending: 'Pendiente', 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: { feed: 'Inicio', calendar: 'Calendario', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index ef37aaa..28dd564 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -129,6 +129,52 @@ const messages: LocaleMessages = { pending: 'En attente', 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: { feed: 'Accueil', calendar: 'Calendrier', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index ccce8f7..1dbb181 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -104,6 +104,52 @@ export interface LocaleMessages { pending: 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: { feed: string calendar: string diff --git a/src/modules/events/components/CheckoutReturnDialog.vue b/src/modules/events/components/CheckoutReturnDialog.vue new file mode 100644 index 0000000..c09571b --- /dev/null +++ b/src/modules/events/components/CheckoutReturnDialog.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index 3f50231..ee8f487 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -41,7 +41,15 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import ImageUpload from '@/modules/base/components/ImageUpload.vue' import DatePicker from '@/modules/base/components/DatePicker.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 type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' @@ -124,7 +132,11 @@ const formSchema = toTypedSchema( event_end_time: z.string().optional().default(''), location: z.string().max(500).optional().default(''), 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"), amount_tickets: z.number().min(0).max(100000).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 // extension uses 'sat' and 'sats' interchangeably — accept both. 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({ code: z.ZodIssueCode.custom, path: ['fiat_currency'], @@ -173,7 +185,7 @@ const form = useForm({ event_end_time: '', location: '', currency: 'sat', - allow_fiat: false, + payment_methods: ['lightning'], fiat_currency: 'USD', amount_tickets: 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 { isPrimary: boolean } @@ -243,7 +291,7 @@ async function populateFromEvent(event: TicketedEvent) { event_end_time: end.time, location: event.location ?? '', currency: event.currency ?? 'sat', - allow_fiat: event.allow_fiat ?? false, + payment_methods: paymentMethodsOf(event), fiat_currency: event.fiat_currency ?? 'USD', amount_tickets: event.amount_tickets ?? 0, price_per_ticket: event.price_per_ticket ?? 0, @@ -354,11 +402,12 @@ const onSubmit = form.handleSubmit(async (formValues) => { eventData.banner = null } if (formValues.currency) eventData.currency = formValues.currency - // allow_fiat always sends so a true→false flip propagates on edit; - // fiat_currency only sends when fiat is on (no point persisting a - // rail-currency the backend won't use). - eventData.allow_fiat = formValues.allow_fiat - if (formValues.allow_fiat && formValues.fiat_currency) { + // allow_fiat always sends (derived from the rail list) so a + // true→false flip propagates on edit; fiat_currency only sends when + // fiat is on (no point persisting a rail-currency the backend won't use). + const acceptsFiat = formValues.payment_methods.includes('fiat') + eventData.allow_fiat = acceptsFiat + if (acceptsFiat && formValues.fiat_currency) { eventData.fiat_currency = formValues.fiat_currency } if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets @@ -371,6 +420,7 @@ const onSubmit = form.handleSubmit(async (formValues) => { // survive the round-trip. eventData.extra = { ...(props.event?.extra ?? {}), + payment_methods: formValues.payment_methods, email_notifications: formValues.email_notifications, nostr_notifications: formValues.nostr_notifications, notification_subject: formValues.notification_subject, @@ -660,26 +710,87 @@ const handleOpenChange = (open: boolean) => { -
-
-

Payment methods

-

- Lightning is always available. Enable fiat to also accept - card and bank payments through your configured provider. -

-
-
- - Lightning — always on -
- -
+ + +
+ Payment methods + + Pick the rails buyers can pay with. Turn Lightning off for a + card-only sale. + +
+
+ + + + + + + + + + + + Your LNbits user has no fiat provider configured. Open + LNbits → Account → Fiat providers and add Stripe, PayPal, + or Square to enable card payments. + + + + + +
+ +
+ {{ opt.label }} +

{{ opt.hint }}

+
+
+
+
+
+ +
+
+ + + + Fiat currency + + + + Currency card buyers are charged in. + + + -
+ +
-

Login Required

+

{{ t('events.purchase.loginRequiredTitle') }}

- Please log in to your account to purchase tickets using your wallet. + {{ t('events.purchase.loginRequiredFree') }}

- +
+ + +
- -
-
+ +
+
Purchasing as: @@ -309,8 +406,44 @@ onUnmounted(() => {
- +
+
+ + {{ t('events.purchase.contact.title') }} +
+ + + {{ t('events.purchase.contact.name') }} * + + + + + + + + + {{ t('events.purchase.contact.email') }} * + + + + {{ t('events.purchase.contact.emailHint') }} + + + + + + {{ t('events.purchase.contact.confirmEmail') }} * + + + + + + +
+ + +
Wallet Status: @@ -329,7 +462,7 @@ onUnmounted(() => { Empty
-
+
Auto-payment available
@@ -347,7 +480,7 @@ onUnmounted(() => {
+ the whole purchase, N ticket rows (one per attendee). -->
Tickets:
@@ -392,18 +525,15 @@ onUnmounted(() => { from="sat" :to="event.fiat_currency" prefix="Equivalent ~" - suffix=" if paid in fiat" + suffix=" if paid by card" />
- -
+ +
Payment method

Both methods charge the same amount via different rails. @@ -421,26 +551,9 @@ onUnmounted(() => { {{ error || fiatError }}

- -
-
-
Ready to pay with {{ fiatProviderLabel }}
-

- Opens the provider's checkout in a new tab. Your ticket - appears in My Tickets once the payment settles. -

-
- -
- -
+

+ {{ t('events.purchase.fiatRedirectHint') }} +

+
+
- +
-

+

{{ purchasedTicketIds.length > 1 ? `${purchasedTicketIds.length} tickets purchased!` : 'Ticket purchased!' }}

-

+

Each attendee gets their own scannable QR in My Tickets — hand them out independently for the door scan. @@ -576,10 +696,31 @@ onUnmounted(() => { Your ticket is now in My Tickets.

+

+ {{ t('events.checkout.emailedTo', { email: form.values.email }) }} +

+
+ +
+
+ + {{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }} + +
+ +
+ + {{ t('events.checkout.viewTicket') }} + +
-