From 0a21c092ffb2a4d2bd64bc1ef2065fe5ae124aca Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:22:12 +0200 Subject: [PATCH 1/6] feat(events): promo code wire types, validate endpoint client, pure helpers Types follow upstream v2 names (PromoCode.max_uses / used_count, PromoValidateRequest, BasketDiscount, BasketTotals) as served by the events extension >= v1.6.1-aio.12; EventExtra.promo_codes becomes optional because public event responses no longer carry it. TicketApiService.validatePromoCode posts the upper-cased code + quantity to POST /events/api/v1/promo/validate/{event_id}; request() now attaches the HTTP status to thrown errors so a 404 from an older extension can be told apart from a 400. lib/promoCodes.ts holds the pure helpers shared by the organizer editor and the buyer dialog (normalise, editor validation returning i18n keys, wire <-> row mapping, display-only percent rounding). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- src/modules/events/lib/promoCodes.spec.ts | 93 +++++++++++++ src/modules/events/lib/promoCodes.ts | 125 ++++++++++++++++++ .../events/services/TicketApiService.spec.ts | 54 ++++++++ .../events/services/TicketApiService.ts | 25 +++- src/modules/events/types/ticket.ts | 37 +++++- 5 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 src/modules/events/lib/promoCodes.spec.ts create mode 100644 src/modules/events/lib/promoCodes.ts diff --git a/src/modules/events/lib/promoCodes.spec.ts b/src/modules/events/lib/promoCodes.spec.ts new file mode 100644 index 0000000..8369c3f --- /dev/null +++ b/src/modules/events/lib/promoCodes.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + applyPercent, + fromWirePromoCodes, + hasPromoErrors, + isUnsupportedEndpointError, + normalizePromoCode, + toWirePromoCodes, + validatePromoRows, + type PromoCodeRow, +} from './promoCodes' + +function row(overrides: Partial = {}): PromoCodeRow { + return { code: 'HALF', discount_percent: 50, max_uses: null, active: true, ...overrides } +} + +describe('normalizePromoCode', () => { + it('trims and upper-cases, and tolerates null', () => { + expect(normalizePromoCode(' summer24 ')).toBe('SUMMER24') + expect(normalizePromoCode(null)).toBe('') + expect(normalizePromoCode(undefined)).toBe('') + }) +}) + +describe('validatePromoRows', () => { + it('accepts a well-formed row', () => { + const errors = validatePromoRows([row()]) + expect(errors).toEqual([{}]) + expect(hasPromoErrors(errors)).toBe(false) + }) + + it('flags an empty code', () => { + expect(validatePromoRows([row({ code: ' ' })])[0].code).toBe('events.create.promo.errors.required') + }) + + it('flags duplicates case-insensitively on both rows', () => { + const errors = validatePromoRows([row({ code: 'half' }), row({ code: ' HALF ' })]) + expect(errors[0].code).toBe('events.create.promo.errors.duplicate') + expect(errors[1].code).toBe('events.create.promo.errors.duplicate') + expect(hasPromoErrors(errors)).toBe(true) + }) + + it('requires the percentage to be within 1..100', () => { + for (const pct of [null, 0, 101, Number.NaN]) { + expect(validatePromoRows([row({ discount_percent: pct })])[0].discount_percent) + .toBe('events.create.promo.errors.percentRange') + } + expect(validatePromoRows([row({ discount_percent: 100 })])[0].discount_percent).toBeUndefined() + }) + + it('allows blank max uses and rejects zero / fractions', () => { + expect(validatePromoRows([row({ max_uses: null })])[0].max_uses).toBeUndefined() + expect(validatePromoRows([row({ max_uses: 3 })])[0].max_uses).toBeUndefined() + expect(validatePromoRows([row({ max_uses: 0 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin') + expect(validatePromoRows([row({ max_uses: 1.5 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin') + }) +}) + +describe('toWirePromoCodes / fromWirePromoCodes', () => { + it('normalises codes, drops used_count and maps blank max uses to null', () => { + expect(toWirePromoCodes([row({ code: ' half ', used_count: 4 }), row({ code: 'CAP', max_uses: 2 })])).toEqual([ + { code: 'HALF', discount_percent: 50, active: true, max_uses: null }, + { code: 'CAP', discount_percent: 50, active: true, max_uses: 2 }, + ]) + }) + + it('round-trips the wire shape and tolerates the older three-field shape', () => { + expect(fromWirePromoCodes([{ code: 'OLD', discount_percent: 20, active: false }])).toEqual([ + { code: 'OLD', discount_percent: 20, max_uses: null, active: false, used_count: 0 }, + ]) + expect(fromWirePromoCodes(undefined)).toEqual([]) + expect(fromWirePromoCodes([{ code: 'CAP', discount_percent: 10, active: true, max_uses: 0, used_count: 2 }])) + .toEqual([{ code: 'CAP', discount_percent: 10, max_uses: null, active: true, used_count: 2 }]) + }) +}) + +describe('applyPercent', () => { + it('rounds sats down to whole units and fiat to two decimals', () => { + expect(applyPercent(333, 15, 'sat')).toBe(283) + expect(applyPercent(59.97, 33, 'EUR')).toBe(40.18) + expect(applyPercent(1000, 100, 'sats')).toBe(0) + }) +}) + +describe('isUnsupportedEndpointError', () => { + it('recognises 404/405 and nothing else', () => { + expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 404 }))).toBe(true) + expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 405 }))).toBe(true) + expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 400 }))).toBe(false) + expect(isUnsupportedEndpointError(new Error('x'))).toBe(false) + expect(isUnsupportedEndpointError(null)).toBe(false) + }) +}) diff --git a/src/modules/events/lib/promoCodes.ts b/src/modules/events/lib/promoCodes.ts new file mode 100644 index 0000000..812b62e --- /dev/null +++ b/src/modules/events/lib/promoCodes.ts @@ -0,0 +1,125 @@ +import type { PromoCode } from '../types/ticket' + +/** + * Promo-code helpers shared by the organizer editor and the buyer + * dialog. Pure functions, no Vue — mirror `promo.py` in the events + * extension by name so the two sides stay recognisably in step: + * + * normalizePromoCode ↔ promo.normalize_code + * applyPercent ↔ promo.round_amount / basket_totals (display fallback only) + * + * The server's `POST /events/api/v1/promo/validate/{event_id}` is the + * source of truth for the discounted total; `applyPercent` exists so + * the UI can still show *something* against an older extension. + */ + +/** One editor row. `discount_percent` / `max_uses` are `null` while the + * organizer is still typing (empty inputs), never `0`-as-empty. */ +export interface PromoCodeRow { + code: string + discount_percent: number | null + /** `null` = unlimited (backend `max_uses: None`). */ + max_uses: number | null + active: boolean + /** Derived by the backend from paid tickets; read-only in the editor. */ + used_count?: number +} + +/** Per-row validation messages as i18n keys (the component runs them + * through `t()`); a missing field means the field is fine. */ +export interface PromoRowErrors { + code?: string + discount_percent?: string + max_uses?: string +} + +const ERROR_KEYS = { + required: 'events.create.promo.errors.required', + duplicate: 'events.create.promo.errors.duplicate', + percentRange: 'events.create.promo.errors.percentRange', + maxUsesMin: 'events.create.promo.errors.maxUsesMin', +} as const + +/** Trim + upper-case, the same normalisation the backend applies before + * matching, so what the buyer sees in the input is what gets matched. */ +export function normalizePromoCode(raw: string | null | undefined): string { + return (raw ?? '').trim().toUpperCase() +} + +/** + * Editor rules — stricter than the backend on purpose: the backend + * accepts 0 % (a no-op the buyer would see as "code not valid"), the + * editor requires 1–100 so an organizer cannot create a dud by accident. + */ +export function validatePromoRows(rows: PromoCodeRow[]): PromoRowErrors[] { + const seen = new Map() + for (const row of rows) { + const code = normalizePromoCode(row.code) + if (code) seen.set(code, (seen.get(code) ?? 0) + 1) + } + return rows.map((row) => { + const errors: PromoRowErrors = {} + const code = normalizePromoCode(row.code) + if (!code) { + errors.code = ERROR_KEYS.required + } else if ((seen.get(code) ?? 0) > 1) { + errors.code = ERROR_KEYS.duplicate + } + const pct = row.discount_percent + if (pct === null || !Number.isFinite(pct) || pct < 1 || pct > 100) { + errors.discount_percent = ERROR_KEYS.percentRange + } + const max = row.max_uses + if (max !== null && (!Number.isInteger(max) || max < 1)) { + errors.max_uses = ERROR_KEYS.maxUsesMin + } + return errors + }) +} + +export function hasPromoErrors(errors: PromoRowErrors[]): boolean { + return errors.some((e) => Object.keys(e).length > 0) +} + +/** Editor rows → wire `extra.promo_codes`. Drops the read-only + * `used_count` (the backend recomputes it) and blank max-uses → `null`. */ +export function toWirePromoCodes(rows: PromoCodeRow[]): PromoCode[] { + return rows.map((row) => ({ + code: normalizePromoCode(row.code), + discount_percent: row.discount_percent ?? 0, + active: row.active, + max_uses: row.max_uses && row.max_uses > 0 ? Math.trunc(row.max_uses) : null, + })) +} + +/** Wire `extra.promo_codes` → editor rows. Tolerates the pre-`max_uses` + * shape (older extension) and a missing list (public records). */ +export function fromWirePromoCodes(codes: PromoCode[] | null | undefined): PromoCodeRow[] { + return (codes ?? []).map((c) => ({ + code: c.code, + discount_percent: c.discount_percent ?? 0, + max_uses: c.max_uses && c.max_uses > 0 ? c.max_uses : null, + active: c.active ?? true, + used_count: c.used_count ?? 0, + })) +} + +/** + * Display-only fallback for the discounted total when the validate + * endpoint is unavailable. Rounds like the backend: whole sats, two + * decimals for fiat. + */ +export function applyPercent(subtotal: number, percent: number, currency: string): number { + const raw = subtotal * (1 - percent / 100) + const unit = currency.toLowerCase() + if (unit === 'sat' || unit === 'sats') return Math.max(0, Math.trunc(raw)) + return Math.max(0, Math.round(raw * 100) / 100) +} + +/** An older extension without the validate route answers 404 (or 405 + * when the path exists for another method). Anything else is a real + * error. */ +export function isUnsupportedEndpointError(err: unknown): boolean { + const status = (err as { status?: unknown } | null)?.status + return status === 404 || status === 405 +} diff --git a/src/modules/events/services/TicketApiService.spec.ts b/src/modules/events/services/TicketApiService.spec.ts index 999ac80..3559f03 100644 --- a/src/modules/events/services/TicketApiService.spec.ts +++ b/src/modules/events/services/TicketApiService.spec.ts @@ -90,3 +90,57 @@ describe('TicketApiService.getTicket / resendTicketEmail', () => { expect(legacy.email).toEqual({ attempted: false, sent: false, error: null }) }) }) + +describe('TicketApiService promo codes', () => { + 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('validatePromoCode posts the v2 shape with an upper-cased code', async () => { + const totals = { + subtotal: 2000, discount: 1000, total: 1000, currency: 'sat', + discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }], + } + fetchMock.mockResolvedValue(okJson(totals)) + const res = await api.validatePromoCode('evt', ' half ', 2) + const { url, init, body } = lastCall() + expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`) + expect(init.method).toBe('POST') + expect(body).toEqual({ codes: ['HALF'], quantity: 2 }) + expect(res).toEqual(totals) + }) + + it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response) + await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 }) + }) + + it('requestTicket forwards promo_code verbatim', async () => { + fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) + await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 }) + expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 }) + }) + + it('createEvent sends extra.promo_codes verbatim', async () => { + fetchMock.mockResolvedValue(okJson({ id: 'evt' })) + const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }] + await api.createEvent( + { name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } }, + 'inkey', + ) + const { body, headers } = lastCall() + expect(body.extra.promo_codes).toEqual(promo_codes) + expect(headers['X-API-KEY']).toBe('inkey') + }) +}) diff --git a/src/modules/events/services/TicketApiService.ts b/src/modules/events/services/TicketApiService.ts index 9a2da22..2f8b291 100644 --- a/src/modules/events/services/TicketApiService.ts +++ b/src/modules/events/services/TicketApiService.ts @@ -1,4 +1,5 @@ import type { + BasketTotals, EventTicket, EventTicketExtra, CreateTicketRequest, @@ -9,7 +10,9 @@ import type { TicketResendResult, TicketedEvent, CreateEventRequest, + PromoValidateRequest, } from '../types/ticket' +import { normalizePromoCode } from '../lib/promoCodes' export interface TicketApiConfig { baseUrl: string @@ -335,6 +338,8 @@ export class TicketApiService { name?: string | null registered: boolean registered_at: string | null + /** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */ + applied_promo_code?: string | null }> }> { return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, { @@ -343,6 +348,22 @@ export class TicketApiService { }) } + /** + * Preview what a promo code does to a purchase of `quantity` tickets + * (`POST /events/api/v1/promo/validate/{event_id}`, anonymous). The + * backend applies the first valid code; an empty `discounts_applied` + * means "not valid". Older extensions 404 — the thrown error carries + * `status` so callers can fall back to "applied at checkout". + */ + async validatePromoCode(eventId: string, code: string, quantity = 1): Promise { + const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity } + return this.request(`/events/api/v1/promo/validate/${eventId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + /** * Mark a paid ticket as registered at the door. Organizer-only — * requires the event-owning wallet's admin_key. Backend rejects @@ -441,7 +462,9 @@ export class TicketApiService { : Array.isArray(error.detail) ? error.detail[0]?.msg ?? 'Request failed' : 'Request failed' - throw new Error(errorMessage) + // Carry the HTTP status so callers can tell "route missing on an + // older extension" (404) apart from "bad input" (400). + throw Object.assign(new Error(errorMessage), { status: response.status }) } return response.json() diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index d42b3c2c..67ee90f 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -10,6 +10,38 @@ export interface PromoCode { code: string discount_percent: number active: boolean + /** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12, + * same name as upstream v2). */ + max_uses?: number | null + /** Paid tickets that used this code — derived by the backend on read, + * never sent back. */ + used_count?: number +} + +/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream + * v2 shape; `quantity` stands in for v2's `items` since this fork has + * no ticket types). */ +export interface PromoValidateRequest { + codes: string[] + quantity: number +} + +export interface BasketDiscount { + code: string + discount_percent: number | null + /** Always null on this fork (percent-only); kept for the v2 shape. */ + discount_fixed: number | null + amount_saved: number +} + +/** Response of the promo validate endpoint. An empty `discounts_applied` + * means none of the submitted codes is valid for this event/quantity. */ +export interface BasketTotals { + subtotal: number + discount: number + total: number + discounts_applied: BasketDiscount[] + currency: string } /** @@ -19,7 +51,10 @@ export interface PromoCode { * in upstream v1.4.0 (PR #50) and v1.6.0. */ export interface EventExtra { - promo_codes: PromoCode[] + /** Present on organizer-authenticated records only: public event + * responses (`/events/public`, `/events/{id}`) no longer carry the + * code list (events ext >= v1.6.1-aio.12). */ + promo_codes?: PromoCode[] conditional: boolean min_tickets: number email_notifications: boolean -- 2.55.0 From a68fed916d267d2fe19fcf960760c55104533677 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:22:13 +0200 Subject: [PATCH 2/6] i18n(events): promo code strings for the editor and the purchase dialog New events.create.promo.* (first key under a create namespace; the rest of the create dialog remains hardcoded English) and events.purchase.promo.* in en/es/fr + the types interface. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- src/i18n/locales/en.ts | 33 +++++++++++++++++++++++++++++++++ src/i18n/locales/es.ts | 33 +++++++++++++++++++++++++++++++++ src/i18n/locales/fr.ts | 33 +++++++++++++++++++++++++++++++++ src/i18n/types.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f3475df..d0dd194 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -58,6 +58,27 @@ const messages: LocaleMessages = { events: { title: 'Events', createNew: 'Create Event', + create: { + promo: { + title: 'Promo codes', + hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.', + empty: 'No promo codes yet.', + code: 'Code', + codePlaceholder: 'EARLYBIRD', + discount: 'Discount %', + maxUses: 'Max uses', + active: 'Active', + used: 'Used {count}', + add: 'Add code', + removeLabel: 'Remove code {code}', + errors: { + required: 'Enter a code', + duplicate: 'Duplicate code', + percentRange: 'Between 1 and 100', + maxUsesMin: 'Must be 1 or more', + }, + }, + }, noEvents: 'No events found', filters: { all: 'All', @@ -137,6 +158,18 @@ const messages: LocaleMessages = { fiatFailed: 'Card checkout failed', loginRequiredTitle: 'Login required', loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.', + promo: { + label: 'Promo code', + placeholder: 'Enter code', + apply: 'Apply', + remove: 'Remove', + checking: 'Checking…', + applied: '{code} −{percent}%', + discountLine: 'Discount ({code})', + invalid: 'This code is not valid for this purchase.', + unverified: 'Could not verify the code — it will be applied at checkout if valid.', + loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.', + }, contact: { title: 'Your details', name: 'Name', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 1e080fe..2177fc7 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -58,6 +58,27 @@ const messages: LocaleMessages = { events: { title: 'Eventos', createNew: 'Crear evento', + create: { + promo: { + title: 'Códigos promocionales', + hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.', + empty: 'Aún no hay códigos promocionales.', + code: 'Código', + codePlaceholder: 'EARLYBIRD', + discount: 'Descuento %', + maxUses: 'Usos máx.', + active: 'Activo', + used: 'Usado {count}', + add: 'Añadir código', + removeLabel: 'Eliminar el código {code}', + errors: { + required: 'Introduce un código', + duplicate: 'Código duplicado', + percentRange: 'Entre 1 y 100', + maxUsesMin: 'Debe ser 1 o más', + }, + }, + }, noEvents: 'No se encontraron eventos', filters: { all: 'Todas', @@ -137,6 +158,18 @@ const messages: LocaleMessages = { 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.', + promo: { + label: 'Código promocional', + placeholder: 'Introduce el código', + apply: 'Aplicar', + remove: 'Quitar', + checking: 'Comprobando…', + applied: '{code} −{percent}%', + discountLine: 'Descuento ({code})', + invalid: 'Este código no es válido para esta compra.', + unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.', + loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.', + }, contact: { title: 'Tus datos', name: 'Nombre', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 7c16e79..54b3676 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -58,6 +58,27 @@ const messages: LocaleMessages = { events: { title: 'Événements', createNew: 'Créer un événement', + create: { + promo: { + title: 'Codes promo', + hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.', + empty: 'Aucun code promo pour le moment.', + code: 'Code', + codePlaceholder: 'EARLYBIRD', + discount: 'Remise %', + maxUses: 'Utilisations max.', + active: 'Actif', + used: 'Utilisé {count}', + add: 'Ajouter un code', + removeLabel: 'Supprimer le code {code}', + errors: { + required: 'Saisissez un code', + duplicate: 'Code en double', + percentRange: 'Entre 1 et 100', + maxUsesMin: 'Doit être 1 ou plus', + }, + }, + }, noEvents: 'Aucun événement trouvé', filters: { all: 'Tout', @@ -137,6 +158,18 @@ const messages: LocaleMessages = { 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.', + promo: { + label: 'Code promo', + placeholder: 'Saisir le code', + apply: 'Appliquer', + remove: 'Retirer', + checking: 'Vérification…', + applied: '{code} −{percent}%', + discountLine: 'Remise ({code})', + invalid: "Ce code n'est pas valable pour cet achat.", + unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.", + loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.', + }, contact: { title: 'Vos coordonnées', name: 'Nom', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index c41389f..2a1b004 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -59,6 +59,27 @@ export interface LocaleMessages { events?: { title: string createNew: string + create: { + promo: { + title: string + hint: string + empty: string + code: string + codePlaceholder: string + discount: string + maxUses: string + active: string + used: string + add: string + removeLabel: string + errors: { + required: string + duplicate: string + percentRange: string + maxUsesMin: string + } + } + } noEvents: string filters: { all: string @@ -112,6 +133,18 @@ export interface LocaleMessages { fiatFailed: string loginRequiredTitle: string loginRequiredFree: string + promo: { + label: string + placeholder: string + apply: string + remove: string + checking: string + applied: string + discountLine: string + invalid: string + unverified: string + loginRequiredDiscount: string + } contact: { title: string name: string -- 2.55.0 From 262e674f9f8b29b6f0f5307a1526afb7f3c56753 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:22:13 +0200 Subject: [PATCH 3/6] fix(events): let the organizer's own record win when merging My Events The authenticated fetch returns the full Event (incl. extra.promo_codes, which public responses omit as of events ext v1.6.1-aio.12) and is what the edit dialog must prefill from. Public records still win for events the user does not own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- src/modules/events/composables/useMyEvents.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/modules/events/composables/useMyEvents.ts b/src/modules/events/composables/useMyEvents.ts index 9def6d1..2012614 100644 --- a/src/modules/events/composables/useMyEvents.ts +++ b/src/modules/events/composables/useMyEvents.ts @@ -13,9 +13,10 @@ export function useMyEvents() { // and merge them into the feed. Otherwise an event that drops to // `proposed` after a non-admin edit disappears from the user's view // entirely — they'd be unable to find it to make a follow-up edit or - // monitor its approval status. Public approved events from other - // users take precedence on dedup (server is the source of truth for - // the public view). + // monitor its approval status. For the user's OWN events the + // authenticated record wins on dedup: it is the full `Event` (incl. + // `extra.promo_codes`, which public responses omit), and it is what + // the edit dialog must prefill from. const fetchAll = async (): Promise => { const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[] @@ -26,9 +27,11 @@ export function useMyEvents() { try { const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[] - const seen = new Set(publicEvents.map((e) => e.id)) - const own = myEvents.filter((e) => !seen.has(e.id)) - return [...publicEvents, ...own] + const own = new Map(myEvents.map((e) => [e.id, e])) + const merged = publicEvents.map((e) => own.get(e.id) ?? e) + const publicIds = new Set(publicEvents.map((e) => e.id)) + const ownOnly = myEvents.filter((e) => !publicIds.has(e.id)) + return [...merged, ...ownOnly] } catch (err) { // Falling back to just the public feed is acceptable — the user // can still browse, they just won't see their own pending events. -- 2.55.0 From 4162e0af99e204b85f606e619d5a2453d95de154 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:22:13 +0200 Subject: [PATCH 4/6] feat(events): promo codes editor in the create/edit event dialog Collapsible "Promo codes" section: rows of code (upper-cased as typed), discount %, max uses (blank = unlimited), active switch, remove; per-row errors from validatePromoRows gate submit alongside the vee-validate form. extra.promo_codes is always sent explicitly (empty editor = clear); edit mode prefills from the authenticated record. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- .../events/components/CreateEventDialog.vue | 56 ++++++- .../events/components/PromoCodesEditor.vue | 153 ++++++++++++++++++ 2 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 src/modules/events/components/PromoCodesEditor.vue diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index ee8f487..ea10109 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { Bell, ChevronDown } from 'lucide-vue-next' +import { Bell, ChevronDown, Tag } from 'lucide-vue-next' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, @@ -55,6 +55,14 @@ import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ import type { TicketApiService } from '../services/TicketApiService' import type { CreateEventRequest, TicketedEvent } from '../types/ticket' import { ALL_CATEGORIES } from '../types/category' +import PromoCodesEditor from './PromoCodesEditor.vue' +import { + fromWirePromoCodes, + hasPromoErrors, + toWirePromoCodes, + validatePromoRows, + type PromoCodeRow, +} from '../lib/promoCodes' interface Props { open: boolean @@ -252,6 +260,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time: // edit-mode population with start-date side effects mid-setValues. const isPopulating = ref(false) const notificationsOpen = ref(false) +const promoOpen = ref(false) // Auto-mirror end date to start: when the user picks a start date, // surface that same date in the end-date picker so a one-day event @@ -277,6 +286,10 @@ const imageService = injectService(SERVICE_TOKENS.IMAGE_UPLO const availableCurrencies = ref(['sat']) const loadingCurrencies = ref(false) const selectedCategories = ref([]) +// Promo codes live outside vee-validate like categories: a plain list +// with its own pure validator (`validatePromoRows`), gating submit below. +const promoRows = ref([]) +const promoErrors = computed(() => validatePromoRows(promoRows.value)) async function populateFromEvent(event: TicketedEvent) { isPopulating.value = true @@ -301,6 +314,10 @@ async function populateFromEvent(event: TicketedEvent) { notification_body: event.extra?.notification_body ?? '', }) selectedCategories.value = [...(event.categories ?? [])] + // Codes come only on the organizer-authenticated record (public + // responses omit them) — useMyEvents / EventDetailPage hand us that one. + promoRows.value = fromWirePromoCodes(event.extra?.promo_codes) + promoOpen.value = promoRows.value.length > 0 if (event.banner) { // Re-render the stored banner via its pict-rs file ID. delete_token // is intentionally empty: we don't own the original upload's token @@ -339,11 +356,13 @@ watch(() => props.open, async (isOpen) => { } } else { selectedCategories.value = [] + promoRows.value = [] + promoOpen.value = false } }) const { resetForm, meta } = form -const isFormValid = computed(() => meta.value.valid) +const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value)) const isLoading = ref(false) const today = computed(() => format(new Date(), 'yyyy-MM-dd')) @@ -414,10 +433,11 @@ const onSubmit = form.handleSubmit(async (formValues) => { if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value - // Notification config goes inside the `extra` envelope. On edit - // overlay onto the existing event.extra so unrelated fields the - // LNbits admin UI sets (promo_codes, conditional, min_tickets) - // survive the round-trip. + // Notification config + promo codes go inside the `extra` envelope. + // On edit overlay onto the existing event.extra so fields only the + // LNbits admin UI sets (conditional, min_tickets) survive the + // round-trip. promo_codes is always sent explicitly: what the + // organizer sees in the editor is the truth, an empty list clears. eventData.extra = { ...(props.event?.extra ?? {}), payment_methods: formValues.payment_methods, @@ -425,6 +445,7 @@ const onSubmit = form.handleSubmit(async (formValues) => { nostr_notifications: formValues.nostr_notifications, notification_subject: formValues.notification_subject, notification_body: formValues.notification_body, + promo_codes: toWirePromoCodes(promoRows.value), } if (isEditMode.value) { @@ -455,6 +476,7 @@ const onSubmit = form.handleSubmit(async (formValues) => { resetForm() selectedCategories.value = [] + promoRows.value = [] bannerImages.value = [] emit('update:open', false) } catch (error) { @@ -474,6 +496,7 @@ const handleOpenChange = (open: boolean) => { if (!open && !isLoading.value) { resetForm() selectedCategories.value = [] + promoRows.value = [] bannerImages.value = [] } emit('update:open', open) @@ -854,6 +877,27 @@ const handleOpenChange = (open: boolean) => { + + + + + + + + + + +
+
+ + {{ t('events.purchase.promo.label') }} +
+ +
+ + + + + +
+
+ + +
+ +

+ {{ t('events.purchase.promo.invalid') }} +

+

+ {{ t('events.purchase.promo.unverified') }} +

+ + + + + {{ t('events.purchase.promo.loginRequiredDiscount') }} + + + +
+ -
+
Wallet Status: @@ -520,11 +668,22 @@ onUnmounted(() => { {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} - {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }} + + + + + +
+
+ {{ t('events.purchase.promo.discountLine', { code: promoState.code }) }} + −{{ formatEventPrice(promoDiscountAmount, event.currency) }}
{ -
+
Payment method

Both methods charge the same amount via different rails. @@ -556,11 +715,11 @@ onUnmounted(() => { -

+

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

@@ -612,7 +771,7 @@ onUnmounted(() => {
Amount - {{ formatEventPrice(totalPrice, event.currency) }} + {{ formatEventPrice(effectiveTotal, event.currency) }} ({{ quantity }} tickets) diff --git a/src/modules/events/composables/usePromoCode.ts b/src/modules/events/composables/usePromoCode.ts new file mode 100644 index 0000000..0642fe7 --- /dev/null +++ b/src/modules/events/composables/usePromoCode.ts @@ -0,0 +1,127 @@ +import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { TicketApiService } from '../services/TicketApiService' +import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes' + +/** + * Buyer-side promo state for the purchase dialog. + * + * - `idle` no code entered / cleared + * - `checking` validate request in flight + * - `applied` server confirmed the code; totals are authoritative + * - `invalid` server answered but the code is not valid for this + * event/quantity (unknown, inactive, exhausted, 0 %) + * - `unverified` older extension without the validate route (404/405) + * or a network failure: keep the code and let the + * purchase endpoint decide — it still discounts on + * every extension version. + */ +export type PromoState = + | { status: 'idle' } + | { status: 'checking'; code: string } + | { + status: 'applied' + code: string + discountPercent: number + subtotal: number + discount: number + total: number + currency: string + } + | { status: 'invalid'; code: string } + | { status: 'unverified'; code: string } + +const REVALIDATE_DEBOUNCE_MS = 300 + +export function usePromoCode(eventId: MaybeRefOrGetter, quantity: Ref) { + const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService + + /** What the buyer typed (kept upper-case by the input handler). */ + const input = ref('') + const state = ref({ status: 'idle' }) + + // Monotonic request counter so a slow response for a previous code or + // quantity can never overwrite a newer result. + let requestSeq = 0 + let debounceTimer: ReturnType | null = null + + const isChecking = computed(() => state.value.status === 'checking') + + /** Code to put on the purchase request. `invalid` deliberately sends + * nothing so the buyer is not surprised by a 400 at pay time. */ + const codeToSend = computed(() => { + const s = state.value + return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined + }) + + const appliedTotal = computed(() => + state.value.status === 'applied' ? state.value.total : null, + ) + + async function validate(code: string): Promise { + const seq = ++requestSeq + state.value = { status: 'checking', code } + try { + const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value) + if (seq !== requestSeq) return + const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0] + if (!hit) { + state.value = { status: 'invalid', code } + return + } + state.value = { + status: 'applied', + code: hit.code, + discountPercent: hit.discount_percent ?? 0, + subtotal: totals.subtotal, + discount: totals.discount, + total: totals.total, + currency: totals.currency, + } + } catch (err) { + if (seq !== requestSeq) return + if (isUnsupportedEndpointError(err)) { + state.value = { status: 'unverified', code } + return + } + // Network / 5xx: don't block the purchase on an advisory call. + console.warn('[usePromoCode] validate failed, sending code unverified:', err) + state.value = { status: 'unverified', code } + } + } + + async function apply(): Promise { + const code = normalizePromoCode(input.value) + if (!code) return + input.value = code + await validate(code) + } + + function clear(): void { + requestSeq++ + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = null + input.value = '' + state.value = { status: 'idle' } + } + + /** Re-check the current code (quantity changed, or the purchase + * endpoint rejected it — e.g. the last use went to someone else). */ + async function revalidate(): Promise { + const s = state.value + if (s.status === 'idle' || s.status === 'checking') return + await validate(s.code) + } + + watch(quantity, () => { + const s = state.value + if (s.status === 'idle') return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + debounceTimer = null + void revalidate() + }, REVALIDATE_DEBOUNCE_MS) + }) + + return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate } +} diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index ea763d1..5854497 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -21,6 +21,8 @@ export interface PurchaseTicketOptions { contact?: PurchaseContact /** Delivery email for a logged-in buyer (guests carry it in `contact`). */ email?: string + /** Promo code to redeem (already normalised by the dialog). */ + promoCode?: string } export function useTicketPurchase() { @@ -173,6 +175,7 @@ export function useTicketPurchase() { accessToken: isGuest ? undefined : accessToken, frontendUrl: frontendRoot(), quantity: options.quantity, + promoCode: options.promoCode, }) // Free tickets (price 0 / 100%-off promo): the backend already @@ -322,6 +325,10 @@ export function useTicketPurchase() { handleOpenLightningWallet, resetPaymentState, cleanup, - generateTicketQRCode + generateTicketQRCode, + // Exposed so the dialog's fiat branch can land a 100 %-off purchase + // (backend returns `paid: true`, no checkout URL) on the same + // success screen as the Lightning / free paths. + finalizePurchasedTickets } } -- 2.55.0 From 7dbe04afa338285715c7c2eb7adeace879e6f373 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:22:13 +0200 Subject: [PATCH 6/6] feat(events): show the redeemed promo code in the scanner roster Maps applied_promo_code from the stats endpoint onto the roster rows as a small outline badge and includes it in the fuzzy search keys. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- src/modules/events/composables/useTicketScanner.ts | 3 +++ src/modules/events/views/ScanTicketsPage.vue | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/modules/events/composables/useTicketScanner.ts b/src/modules/events/composables/useTicketScanner.ts index 1afe819..ee94c87 100644 --- a/src/modules/events/composables/useTicketScanner.ts +++ b/src/modules/events/composables/useTicketScanner.ts @@ -28,6 +28,8 @@ export interface EventTicket { name?: string | null registered: boolean registeredAt: string | null + /** Promo code redeemed on this ticket, when the extension reports it. */ + promoCode?: string | null } /** Counts + roster snapshot for the event, sourced from the backend. */ @@ -110,6 +112,7 @@ export function useTicketScanner(eventId: Ref) { name: t.name ?? null, registered: t.registered, registeredAt: t.registered_at, + promoCode: t.applied_promo_code ?? null, })), } } catch (e) { diff --git a/src/modules/events/views/ScanTicketsPage.vue b/src/modules/events/views/ScanTicketsPage.vue index e081fd5..85c70ba 100644 --- a/src/modules/events/views/ScanTicketsPage.vue +++ b/src/modules/events/views/ScanTicketsPage.vue @@ -104,8 +104,9 @@ const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch( { fuseOptions: { keys: [ - { name: 'name', weight: 0.7 }, - { name: 'id', weight: 0.3 }, + { name: 'name', weight: 0.6 }, + { name: 'id', weight: 0.25 }, + { name: 'promoCode', weight: 0.15 }, ], threshold: 0.3, ignoreLocation: true, @@ -296,6 +297,14 @@ function fmtTime(iso: string) { {{ ticket.name }} + + {{ ticket.promoCode }} +

{{ ticket.id }} -- 2.55.0