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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
Padreug 2026-09-06 19:30:47 +02:00
commit f85848a50b
12 changed files with 450 additions and 56 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,8 +3,10 @@ import type {
EventTicketExtra,
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<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.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<string, string> = { '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<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.
*/
@ -226,22 +274,30 @@ 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<EventTicket> {
const t = await this.request(
): Promise<TicketResendResult> {
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 {
ticket: {
id: t.id,
wallet: t.wallet,
eventId: t.event,
@ -253,6 +309,9 @@ export class TicketApiService {
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined,
},
email: delivery(r.email),
nostr: delivery(r.nostr),
}
}

View file

@ -84,6 +84,8 @@ export interface EventTicketInfo {
allowFiat: boolean
/** 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,
}
}

View file

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

View file

@ -36,6 +36,10 @@ export interface TicketTags {
allowFiat: boolean
/** 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,
}
}

View file

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