From f85848a50b03423783cef56a11ba59bc4df59041 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:30:47 +0200 Subject: [PATCH 1/7] feat(events): guest identity, frontend_url and public ticket read in the ticket API Prepare the webapp for account-less ticket purchases against events ext v1.6.1-aio.8: - requestTicket takes an options object: user_id (+ optional email) for logged-in buyers, name + email for guests, Authorization only when a token exists, and frontend_url so the backend builds the Stripe return URLs and the emailed ticket link under this app's root. - getTicket (anonymous PublicTicket) and getPublicEvent for the return dialog and the public ticket page. - resendTicketEmail maps the structured TicketResendResult (legacy bare ticket tolerated). - NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods; effectivePaymentMethods() mirrors the backend rule. - renderTicketQr: level-H ticket:// QR with the brand logo centred, shared by every ticket surface; useTicketPurchase accepts a guest contact. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- .../events/composables/useTicketPurchase.ts | 44 ++++-- src/modules/events/lib/frontendRoot.spec.ts | 16 +++ src/modules/events/lib/frontendRoot.ts | 20 +++ src/modules/events/lib/paymentMethods.spec.ts | 21 +++ src/modules/events/lib/paymentMethods.ts | 22 +++ src/modules/events/lib/ticketQr.ts | 61 ++++++++ .../events/services/TicketApiService.spec.ts | 92 ++++++++++++ .../events/services/TicketApiService.ts | 133 +++++++++++++----- src/modules/events/types/event.ts | 3 + src/modules/events/types/nip52.spec.ts | 35 +++++ src/modules/events/types/nip52.ts | 8 ++ src/modules/events/types/ticket.ts | 51 ++++++- 12 files changed, 450 insertions(+), 56 deletions(-) create mode 100644 src/modules/events/lib/frontendRoot.spec.ts create mode 100644 src/modules/events/lib/frontendRoot.ts create mode 100644 src/modules/events/lib/paymentMethods.spec.ts create mode 100644 src/modules/events/lib/paymentMethods.ts create mode 100644 src/modules/events/lib/ticketQr.ts create mode 100644 src/modules/events/services/TicketApiService.spec.ts create mode 100644 src/modules/events/types/nip52.spec.ts diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index 3accb7b..ea763d1 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -6,6 +6,22 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { PaymentService } from '@/core/services/PaymentService' import type { TicketApiService } from '../services/TicketApiService' 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() { const { isAuthenticated, currentUser } = useAuth() @@ -64,11 +80,7 @@ export function useTicketPurchase() { async function generateTicketQRCode(ticketId: string) { try { - const ticketUrl = `ticket://${ticketId}` - const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, { - width: 128, - margin: 2 - }) + const dataUrl = await renderTicketQr(ticketId, { width: 160 }) ticketQRCode.value = dataUrl return dataUrl } catch (error) { @@ -128,9 +140,13 @@ export function useTicketPurchase() { async function purchaseTicketForEvent( 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') } @@ -150,12 +166,14 @@ export function useTicketPurchase() { const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const accessToken = lnbitsAPI?.getAccessToken?.() || '' - const invoice = await ticketApi.requestTicket( - eventId, - currentUser.value!.id, - accessToken, - { quantity: options.quantity }, - ) + const invoice = await ticketApi.requestTicket(eventId, { + userId: isGuest ? undefined : currentUser.value!.id, + name: isGuest ? options.contact?.name : undefined, + email: isGuest ? options.contact?.email : options.email, + accessToken: isGuest ? undefined : accessToken, + frontendUrl: frontendRoot(), + quantity: options.quantity, + }) // Free tickets (price 0 / 100%-off promo): the backend already // issued + marked them paid, so there's no invoice to settle. Skip diff --git a/src/modules/events/lib/frontendRoot.spec.ts b/src/modules/events/lib/frontendRoot.spec.ts new file mode 100644 index 0000000..13f5a68 --- /dev/null +++ b/src/modules/events/lib/frontendRoot.spec.ts @@ -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') + }) +}) diff --git a/src/modules/events/lib/frontendRoot.ts b/src/modules/events/lib/frontendRoot.ts new file mode 100644 index 0000000..6232e68 --- /dev/null +++ b/src/modules/events/lib/frontendRoot.ts @@ -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(/\/+$/, '') +} diff --git a/src/modules/events/lib/paymentMethods.spec.ts b/src/modules/events/lib/paymentMethods.spec.ts new file mode 100644 index 0000000..72c80f2 --- /dev/null +++ b/src/modules/events/lib/paymentMethods.spec.ts @@ -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']) + }) +}) diff --git a/src/modules/events/lib/paymentMethods.ts b/src/modules/events/lib/paymentMethods.ts new file mode 100644 index 0000000..70e08e9 --- /dev/null +++ b/src/modules/events/lib/paymentMethods.ts @@ -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'] +} diff --git a/src/modules/events/lib/ticketQr.ts b/src/modules/events/lib/ticketQr.ts new file mode 100644 index 0000000..51790fa --- /dev/null +++ b/src/modules/events/lib/ticketQr.ts @@ -0,0 +1,61 @@ +import brandLogo from '@brand/logo.png' + +/** + * Render the scannable `ticket://` 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 { + 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 { + 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 { + 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') +} diff --git a/src/modules/events/services/TicketApiService.spec.ts b/src/modules/events/services/TicketApiService.spec.ts new file mode 100644 index 0000000..999ac80 --- /dev/null +++ b/src/modules/events/services/TicketApiService.spec.ts @@ -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 } + } + + 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 }) + }) +}) diff --git a/src/modules/events/services/TicketApiService.ts b/src/modules/events/services/TicketApiService.ts index ae7476d..9a2da22 100644 --- a/src/modules/events/services/TicketApiService.ts +++ b/src/modules/events/services/TicketApiService.ts @@ -3,8 +3,10 @@ import type { EventTicketExtra, CreateTicketRequest, PaymentMethod, + PublicTicket, TicketPurchaseInvoice, TicketPaymentStatus, + TicketResendResult, TicketedEvent, CreateEventRequest, } from '../types/ticket' @@ -14,6 +16,27 @@ export interface TicketApiConfig { 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. * 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 * `isFiat` flag is the discriminator. * - * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into - * the fiat path (requires the event to have `allow_fiat=true`). - * `fiatProvider` is optional — backend picks the user's configured - * default when omitted. + * Identity: pass `userId` for a logged-in buyer (plus `email` so the + * ticket can be delivered by mail), or `name` + `email` for a guest. + * The endpoint itself is anonymous; `accessToken` is only forwarded + * when present. * - * Additional ticket metadata (promo code, refund address, nostr - * identifier for DM delivery) can be supplied via `options`. + * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into + * 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( eventId: string, - userId: string, - 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 - } = {}, + options: RequestTicketOptions = {}, ): Promise { - 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.fiatProvider) body.fiat_provider = options.fiatProvider 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.quantity && options.quantity > 1) body.quantity = options.quantity + const headers: Record = { 'Content-Type': 'application/json' } + if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}` + const data = await this.request( `/events/api/v1/tickets/${eventId}`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, + headers, 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 { + 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 { + return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' }) + } + /** * 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 * 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( ticketId: string, adminKey: string, - ): Promise { - const t = await this.request( + ): Promise { + const r = await this.request( `/events/api/v1/tickets/${ticketId}/resend-email`, { method: 'POST', 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 { - id: t.id, - wallet: t.wallet, - eventId: t.event, - name: t.name, - email: t.email, - userId: t.user_id, - registered: t.registered, - paid: t.paid, - time: t.time, - regTimestamp: t.reg_timestamp, - extra: t.extra as EventTicketExtra | undefined, + ticket: { + id: t.id, + wallet: t.wallet, + eventId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + extra: t.extra as EventTicketExtra | undefined, + }, + email: delivery(r.email), + nostr: delivery(r.nostr), } } diff --git a/src/modules/events/types/event.ts b/src/modules/events/types/event.ts index 88762b3..72d7c4f 100644 --- a/src/modules/events/types/event.ts +++ b/src/modules/events/types/event.ts @@ -84,6 +84,8 @@ export interface EventTicketInfo { allowFiat: boolean /** Fiat settle currency when allowFiat is true. */ fiatCurrency?: string + /** Rails the organizer enabled (see TicketTags.paymentMethods). */ + paymentMethods?: string[] } 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, allowFiat: ticket.allowFiat, fiatCurrency: ticket.fiatCurrency, + paymentMethods: ticket.paymentMethods, } } diff --git a/src/modules/events/types/nip52.spec.ts b/src/modules/events/types/nip52.spec.ts new file mode 100644 index 0000000..cc95583 --- /dev/null +++ b/src/modules/events/types/nip52.spec.ts @@ -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() + }) +}) diff --git a/src/modules/events/types/nip52.ts b/src/modules/events/types/nip52.ts index b87db35..de2d8c1 100644 --- a/src/modules/events/types/nip52.ts +++ b/src/modules/events/types/nip52.ts @@ -36,6 +36,10 @@ export interface TicketTags { allowFiat: boolean /** Fiat settle currency when allowFiat is true. */ 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 priceStr = getTagValue(tags, 'tickets_price') const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat') + const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods') return { available: availableStr != null ? Number(availableStr) : undefined, @@ -145,6 +150,9 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined { currency, allowFiat: allowFiatStr === 'true', fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'), + paymentMethods: paymentMethodsStr != null + ? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean) + : undefined, } } diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index 3c0842e..d42b3c2c 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -26,6 +26,10 @@ export interface EventExtra { nostr_notifications: boolean notification_subject: 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 { @@ -165,17 +169,22 @@ export interface CreateEventRequest { } /** - * Body for `POST /tickets/{event_id}`. Either `user_id` OR the - * `name`+`email` pair is required (backend root_validator enforces - * mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM - * delivery when the event has nostr_notifications enabled. The - * `payment_method` + `fiat_provider` pair selects between Lightning - * and fiat checkout. + * Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in + * buyer, optionally with `email` for ticket delivery) OR the + * `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer + * into Nostr DM delivery when the event has nostr_notifications + * enabled. The `payment_method` + `fiat_provider` pair selects between + * 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 { name?: string email?: 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 refund_address?: string nostr_identifier?: string @@ -184,3 +193,33 @@ export interface CreateTicketRequest { /** Number of tickets on this invoice (backend bounds 1..10). */ 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 +} -- 2.55.0 From 891b5072f8e74d0da737168245ebbb9b56a54c84 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:35:45 +0200 Subject: [PATCH 2/7] feat(events): guest checkout in the purchase dialog with delivery email Buyers no longer need an account to buy a paid ticket. The dialog gains a "Your details" block (name for guests, email + confirm-email for everyone, prefilled from the account email) as a vee-validate/zod form, and builds its rail buttons from the event's published payment methods so an organizer can run card-only events. - Fiat: same-tab redirect to the provider right after the POST, with the pending checkout (ticket ids, email) remembered in localStorage for the return page; the two-step "open in new tab" panel is gone. - Lightning: guests get the bolt11 QR + "Open in wallet" and settle via the existing poll; the LNbits-wallet button stays login-only. - Free tickets remain login-gated (events#29). - Guest success state renders the ticket QRs inline with the "emailed to" note, since guests have no My Tickets. i18n keys added under events.purchase / checkout / ticket (en, es, fr). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- src/i18n/locales/en.ts | 45 ++ src/i18n/locales/es.ts | 45 ++ src/i18n/locales/fr.ts | 45 ++ src/i18n/types.ts | 45 ++ .../components/PurchaseTicketDialog.vue | 419 ++++++++++++------ src/modules/events/lib/pendingCheckout.ts | 38 ++ 6 files changed, 498 insertions(+), 139 deletions(-) create mode 100644 src/modules/events/lib/pendingCheckout.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index dbb408c..b718299 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -129,6 +129,51 @@ 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', + 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..7410846 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -129,6 +129,51 @@ 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', + 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..21392ec 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -129,6 +129,51 @@ 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', + 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..61a8f32 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -104,6 +104,51 @@ 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 + 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/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index a9543f1..53eac52 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -1,13 +1,26 @@ + + diff --git a/src/modules/events/views/EventDetailPage.vue b/src/modules/events/views/EventDetailPage.vue index 10fdbc8..ee08ae8 100644 --- a/src/modules/events/views/EventDetailPage.vue +++ b/src/modules/events/views/EventDetailPage.vue @@ -14,6 +14,8 @@ import { useEventDetail } from '../composables/useEventDetail' import BookmarkButton from '../components/BookmarkButton.vue' import OrganizerCard from '../components/OrganizerCard.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 { useEventsStore } from '../stores/events' import { useOwnedTickets } from '../composables/useOwnedTickets' @@ -116,6 +118,7 @@ const purchaseEvent = computed(() => { currency: a.ticketInfo.currency, allow_fiat: a.ticketInfo.allowFiat, fiat_currency: a.ticketInfo.fiatCurrency, + payment_methods: a.ticketInfo.paymentMethods, } }) @@ -142,7 +145,10 @@ const isPast = computed(() => { const showPurchaseDialog = ref(false) 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'), { action: { label: t('events.detail.logIn'), @@ -154,6 +160,43 @@ function openPurchaseDialog() { 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([]) +const returnEmail = ref(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 // buyer may have just paid). The inventory side updates automatically // via the relay republish from the events extension. @@ -366,6 +409,14 @@ function goToMyTickets() { @update:is-open="showPurchaseDialog = $event" /> + +
-- 2.55.0 From b853fbce4b1e36db4bcc1c4fc470f209413cc0b7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:39:42 +0200 Subject: [PATCH 4/7] feat(events): public ticket page at /events/ticket/:ticketId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target of the emailed ticket link the extension now builds under the webapp root. Anonymous, like the LNbits UI's own /events/ticket/{id}: reads the PublicTicket, resolves the event name (Nostr store → scoped relay query → public REST as a last resort), and renders the logo QR with paid / pending / checked-in state. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- src/modules/events/index.ts | 12 ++ src/modules/events/views/TicketPage.vue | 155 ++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/modules/events/views/TicketPage.vue diff --git a/src/modules/events/index.ts b/src/modules/events/index.ts index e592b7e..0a4d4a3 100644 --- a/src/modules/events/index.ts +++ b/src/modules/events/index.ts @@ -51,6 +51,18 @@ export const eventsModule = createModulePlugin({ 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', name: 'event-detail', diff --git a/src/modules/events/views/TicketPage.vue b/src/modules/events/views/TicketPage.vue new file mode 100644 index 0000000..cb6e9e3 --- /dev/null +++ b/src/modules/events/views/TicketPage.vue @@ -0,0 +1,155 @@ + + + -- 2.55.0 From 5d3ebcc5e49795f63e72c88b3aa925e22398b01e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:43:10 +0200 Subject: [PATCH 5/7] feat(events): per-event payment methods in the create/edit dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "Also accept fiat" switch with a Lightning / Card checkbox group so an organizer can switch Lightning off for a card-only sale. Sends extra.payment_methods (upstream v2 shape) and keeps allow_fiat in sync (fiat in list ⇒ allow_fiat=true) for older readers. Card stays disabled with a tooltip when the organizer has no fiat provider; the fiat-currency picker appears when Card is on and the price is in sats. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- .../events/components/CreateEventDialog.vue | 171 +++++++++++++++--- 1 file changed, 141 insertions(+), 30 deletions(-) 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. + + +