diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d0dd194..f3475df 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -58,27 +58,6 @@ 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', @@ -158,18 +137,6 @@ 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 2177fc7..1e080fe 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -58,27 +58,6 @@ 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', @@ -158,18 +137,6 @@ 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 54b3676..7c16e79 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -58,27 +58,6 @@ 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', @@ -158,18 +137,6 @@ 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 2a1b004..c41389f 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -59,27 +59,6 @@ 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 @@ -133,18 +112,6 @@ 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 diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index ea10109..ee8f487 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, Tag } from 'lucide-vue-next' +import { Bell, ChevronDown } from 'lucide-vue-next' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, @@ -55,14 +55,6 @@ 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 @@ -260,7 +252,6 @@ 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 @@ -286,10 +277,6 @@ 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 @@ -314,10 +301,6 @@ 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 @@ -356,13 +339,11 @@ watch(() => props.open, async (isOpen) => { } } else { selectedCategories.value = [] - promoRows.value = [] - promoOpen.value = false } }) const { resetForm, meta } = form -const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value)) +const isFormValid = computed(() => meta.value.valid) const isLoading = ref(false) const today = computed(() => format(new Date(), 'yyyy-MM-dd')) @@ -433,11 +414,10 @@ 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 + 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. + // 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. eventData.extra = { ...(props.event?.extra ?? {}), payment_methods: formValues.payment_methods, @@ -445,7 +425,6 @@ 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) { @@ -476,7 +455,6 @@ const onSubmit = form.handleSubmit(async (formValues) => { resetForm() selectedCategories.value = [] - promoRows.value = [] bannerImages.value = [] emit('update:open', false) } catch (error) { @@ -496,7 +474,6 @@ const handleOpenChange = (open: boolean) => { if (!open && !isLoading.value) { resetForm() selectedCategories.value = [] - promoRows.value = [] bannerImages.value = [] } emit('update:open', open) @@ -877,27 +854,6 @@ 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: @@ -668,22 +520,11 @@ onUnmounted(() => { {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} - - - - - -
-
- {{ t('events.purchase.promo.discountLine', { code: promoState.code }) }} - −{{ formatEventPrice(promoDiscountAmount, event.currency) }} + {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
{ -
+
Payment method

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

+

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

@@ -771,7 +612,7 @@ onUnmounted(() => {
Amount - {{ formatEventPrice(effectiveTotal, event.currency) }} + {{ formatEventPrice(totalPrice, event.currency) }} ({{ quantity }} tickets) diff --git a/src/modules/events/composables/useMyEvents.ts b/src/modules/events/composables/useMyEvents.ts index 2012614..9def6d1 100644 --- a/src/modules/events/composables/useMyEvents.ts +++ b/src/modules/events/composables/useMyEvents.ts @@ -13,10 +13,9 @@ 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. 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. + // monitor its approval status. Public approved events from other + // users take precedence on dedup (server is the source of truth for + // the public view). const fetchAll = async (): Promise => { const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[] @@ -27,11 +26,9 @@ export function useMyEvents() { try { const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[] - 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] + const seen = new Set(publicEvents.map((e) => e.id)) + const own = myEvents.filter((e) => !seen.has(e.id)) + return [...publicEvents, ...own] } 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. diff --git a/src/modules/events/composables/usePromoCode.ts b/src/modules/events/composables/usePromoCode.ts deleted file mode 100644 index 0642fe7..0000000 --- a/src/modules/events/composables/usePromoCode.ts +++ /dev/null @@ -1,127 +0,0 @@ -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 5854497..ea763d1 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -21,8 +21,6 @@ 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() { @@ -175,7 +173,6 @@ 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 @@ -325,10 +322,6 @@ export function useTicketPurchase() { handleOpenLightningWallet, resetPaymentState, cleanup, - 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 + generateTicketQRCode } } diff --git a/src/modules/events/composables/useTicketScanner.ts b/src/modules/events/composables/useTicketScanner.ts index ee94c87..1afe819 100644 --- a/src/modules/events/composables/useTicketScanner.ts +++ b/src/modules/events/composables/useTicketScanner.ts @@ -28,8 +28,6 @@ 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. */ @@ -112,7 +110,6 @@ 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/lib/promoCodes.spec.ts b/src/modules/events/lib/promoCodes.spec.ts deleted file mode 100644 index 8369c3f..0000000 --- a/src/modules/events/lib/promoCodes.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 812b62e..0000000 --- a/src/modules/events/lib/promoCodes.ts +++ /dev/null @@ -1,125 +0,0 @@ -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 3559f03..999ac80 100644 --- a/src/modules/events/services/TicketApiService.spec.ts +++ b/src/modules/events/services/TicketApiService.spec.ts @@ -90,57 +90,3 @@ 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 2f8b291..9a2da22 100644 --- a/src/modules/events/services/TicketApiService.ts +++ b/src/modules/events/services/TicketApiService.ts @@ -1,5 +1,4 @@ import type { - BasketTotals, EventTicket, EventTicketExtra, CreateTicketRequest, @@ -10,9 +9,7 @@ import type { TicketResendResult, TicketedEvent, CreateEventRequest, - PromoValidateRequest, } from '../types/ticket' -import { normalizePromoCode } from '../lib/promoCodes' export interface TicketApiConfig { baseUrl: string @@ -338,8 +335,6 @@ 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`, { @@ -348,22 +343,6 @@ 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 @@ -462,9 +441,7 @@ export class TicketApiService { : Array.isArray(error.detail) ? error.detail[0]?.msg ?? 'Request failed' : 'Request failed' - // 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 }) + throw new Error(errorMessage) } return response.json() diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index 67ee90f..d42b3c2c 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -10,38 +10,6 @@ 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 } /** @@ -51,10 +19,7 @@ export interface BasketTotals { * in upstream v1.4.0 (PR #50) and v1.6.0. */ export interface EventExtra { - /** 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[] + promo_codes: PromoCode[] conditional: boolean min_tickets: number email_notifications: boolean diff --git a/src/modules/events/views/ScanTicketsPage.vue b/src/modules/events/views/ScanTicketsPage.vue index 85c70ba..e081fd5 100644 --- a/src/modules/events/views/ScanTicketsPage.vue +++ b/src/modules/events/views/ScanTicketsPage.vue @@ -104,9 +104,8 @@ const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch( { fuseOptions: { keys: [ - { name: 'name', weight: 0.6 }, - { name: 'id', weight: 0.25 }, - { name: 'promoCode', weight: 0.15 }, + { name: 'name', weight: 0.7 }, + { name: 'id', weight: 0.3 }, ], threshold: 0.3, ignoreLocation: true, @@ -297,14 +296,6 @@ function fmtTime(iso: string) { {{ ticket.name }} - - {{ ticket.promoCode }} -

{{ ticket.id }}