diff --git a/docs/02-modules/events-module/guest-checkout.md b/docs/02-modules/events-module/guest-checkout.md deleted file mode 100644 index 25c31f7..0000000 --- a/docs/02-modules/events-module/guest-checkout.md +++ /dev/null @@ -1,76 +0,0 @@ -# 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 a91b2bb..8b0fda2 100644 --- a/docs/02-modules/index.md +++ b/docs/02-modules/index.md @@ -182,13 +182,6 @@ 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 19becc3..dbb408c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -129,52 +129,6 @@ 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 7bb588b..9ca8958 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -129,52 +129,6 @@ 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 28dd564..ef37aaa 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -129,52 +129,6 @@ 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 1dbb181..ccce8f7 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -104,52 +104,6 @@ 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 deleted file mode 100644 index c09571b..0000000 --- a/src/modules/events/components/CheckoutReturnDialog.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index ee8f487..3f50231 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -41,15 +41,7 @@ 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 { 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 FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue' import { Alert, AlertDescription } from '@/components/ui/alert' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' @@ -132,11 +124,7 @@ const formSchema = toTypedSchema( event_end_time: z.string().optional().default(''), location: z.string().max(500).optional().default(''), currency: z.string().default("sat"), - // 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'), + allow_fiat: z.boolean().default(false), 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), @@ -164,7 +152,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.payment_methods.includes('fiat') && isSat && !v.fiat_currency) { + if (v.allow_fiat && isSat && !v.fiat_currency) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fiat_currency'], @@ -185,7 +173,7 @@ const form = useForm({ event_end_time: '', location: '', currency: 'sat', - payment_methods: ['lightning'], + allow_fiat: false, fiat_currency: 'USD', amount_tickets: 0, price_per_ticket: 0, @@ -196,42 +184,6 @@ 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 } @@ -291,7 +243,7 @@ async function populateFromEvent(event: TicketedEvent) { event_end_time: end.time, location: event.location ?? '', currency: event.currency ?? 'sat', - payment_methods: paymentMethodsOf(event), + allow_fiat: event.allow_fiat ?? false, fiat_currency: event.fiat_currency ?? 'USD', amount_tickets: event.amount_tickets ?? 0, price_per_ticket: event.price_per_ticket ?? 0, @@ -402,12 +354,11 @@ const onSubmit = form.handleSubmit(async (formValues) => { eventData.banner = null } if (formValues.currency) eventData.currency = formValues.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) { + // 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) { eventData.fiat_currency = formValues.fiat_currency } if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets @@ -420,7 +371,6 @@ 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, @@ -710,87 +660,26 @@ const handleOpenChange = (open: boolean) => { - - -
- 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. - - - +
+
+

Payment methods

+

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

+
+
+ + Lightning — always on +
+ +
-
+ +
-

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

+

Login Required

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

-
- - -
+
- -
-
+ +
+
Purchasing as: @@ -406,44 +309,8 @@ 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: @@ -462,7 +329,7 @@ onUnmounted(() => { Empty
-
+
Auto-payment available
@@ -480,7 +347,7 @@ onUnmounted(() => {
+ the whole purchase, one ticket row representing N seats. -->
Tickets:
@@ -525,15 +392,18 @@ onUnmounted(() => { from="sat" :to="event.fiat_currency" prefix="Equivalent ~" - suffix=" if paid by card" + suffix=" if paid in fiat" />
- -
+ +
Payment method

Both methods charge the same amount via different rails. @@ -551,9 +421,26 @@ 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. @@ -696,31 +576,10 @@ 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') }} - -
-