Compare commits

..

4 commits

Author SHA1 Message Date
072ca8817b fix(events): stop showing a ticket total nothing publishes
The card and detail page rendered "{available} of {total} tickets left",
where total was derived as `available + sold`. That was already wrong
after any organizer edit re-baselined remaining (#143), and waves make
it meaningless: since events ext v1.6.8 `tickets_available` is the
ACTIVE WAVE's stock while `tickets_sold` counts every wave, so the two
no longer share a denominator. An event with 5 left in the open wave and
7 sold across earlier ones displayed "5 of 12".

Nothing publishes a capacity — no tag, no field — so nothing can honestly
show one. Both sites now use the existing `ticketsAvailable` string,
which says only what the data knows.

`EventTicketInfo.total` and the `ticketsRemainingOfTotal` string are
removed rather than left unused, so neither can be picked up again.

Closes #143. Whether to publish a real capacity remains open on
aiolabs/events#34, which narrowed it to either dropping the total (this)
or storing capacity and publishing an explicit `tickets_total` tag.
2026-09-29 08:30:03 +02:00
d55115d0ac feat(events): manage ticket waves from the organizer form
Capacity, price, currency and fiat edits made in the webapp were
silently discarded. Since events ext v1.6.8 the backend derives those
event-level fields FROM the waves on every write, so submitting a
changed `amount_tickets` beside an unchanged wave list left the wave's
old number winning. Verified against a running instance before fixing:
999 / 77 went in, 45 / 10.0 came back, HTTP 200.

The dialog's price / capacity / currency fields ARE the primary wave, so
they are now written into it on submit — the same write-through the
LNbits admin dialog performs. A new "Ticket waves" section manages the
tiers after the first, mirroring PromoCodesEditor: v-model over a row
array, with the pure `validateWaveRows` so submit gates on exactly the
rules the backend enforces.

Validation follows `_validate_wave_capacity`, including its subtlety: a
wave already stored on the event may sit at zero capacity, because that
is what sold out looks like and rejecting it would make a sold-out event
uneditable. Only a newly added wave must state a real capacity. Stored
zeroes render as "Sold out" rather than as an error.

One hazard found while wiring the write-through: on CREATE there is no
stored event, so the implied primary wave was synthesized with empty
date strings — which the backend then throws on for every read
(`ValueError: Invalid isoformat string: ''`). The seed now supplies the
same inputs `create_event` uses, so the wave the webapp sends matches
the one the backend would have built. Pinned by a test.

72 tests; vue-tsc and prettier clean.
2026-09-29 08:28:48 +02:00
afa34c90c2 feat(events): buy from a chosen ticket wave
Checkout was priced and requested off the event, which since events ext
v1.6.8 means the PRIMARY wave. Two things were wrong at once: once early
bird closed the dialog displayed the closed tier's price while the
backend charged the open one, and an event with two waves open could not
be bought at all — the purchase endpoint refuses to guess ("Please
select a ticket wave") and nothing sent `ticket_wave_id`.

The dialog now resolves a wave the way the backend does: a single open
wave is implied, several means the buyer picks, none means nothing is on
sale. Price, currency and fiat availability all read from that wave —
`allow_fiat` too, which is a per-wave opt-in and could otherwise offer a
rail the purchase would refuse. The picker only renders when there is an
actual choice, so the common single-wave case gains no extra click, and
the CTA is disabled rather than firing a request the backend will reject.

`ticket_wave_id` now rides on all three purchase paths (discounted-free,
Lightning, fiat) and on the promo preview, so the quote and the charge
are priced against the same tier.

Plumbing worth noting: the tags a card renders from describe the active
wave but carry no wave id, by the flat-tag decision on aiolabs/events#61.
So the detail page fetches the LNbits record for the pickable tiers —
possible only because `ticket_waves` is public from v1.6.8-aio.2
(aiolabs/events#66). Non-fatal on failure: the backend answers 410 for
sold-out or closed events, where there is no tier to pick anyway.

3 service tests pin the wire format, including that no empty wave id is
sent when the buyer had no choice. 62 pass; vue-tsc and prettier clean.
2026-09-29 08:23:52 +02:00
8490cdf84c feat(events): ticket-wave types and arithmetic
Foundation for wave support. Types only plus a pure lib — no UI yet.

Since events ext v1.6.8 price, currency and stock belong to a ticket
WAVE, and the event-level fields are derived: `amount_tickets` is the sum
across waves, `price_per_ticket` and `currency` are the FIRST wave's.
Reading them to price or count anything is now a bug, which is what the
webapp does today.

`lib/ticketWaves.ts` mirrors the extension's `ensure_ticket_waves`,
`get_active_ticket_waves`, `advertised_ticket_wave` and
`sync_event_ticket_waves`, plus the `_resolve_ticket_wave` selection rule
and the primary-wave write-through the LNbits admin dialog performs.

Every function was cross-checked against the running Python with shared
fixtures rather than written from the docs, which caught one divergence
worth recording: the backend keeps the time component on a synthesized
primary wave's `closing_date`, and `sync_event_ticket_waves` takes a
STRING max over those. Truncating to a date — the obvious-looking
thing, and what this first did — makes the client derive a different
event closing_date than the server. Truncation belongs at comparison
time, in `waveDay`, mirroring the backend's `_parse_date`.

`ticket_wave_id` added to CreateTicketRequest and PromoValidateRequest;
`ticket_waves` to EventExtra. The latter is public from events ext
v1.6.8-aio.2 (aiolabs/events#66) — without that a buyer cannot learn a
wave id at all.

19 tests. vue-tsc and prettier clean; 59 events tests pass.
2026-09-29 08:19:33 +02:00
17 changed files with 4498 additions and 2628 deletions

View file

@ -1,331 +1,341 @@
import type { LocaleMessages } from '../types' import type { LocaleMessages } from "../types";
const messages: LocaleMessages = { const messages: LocaleMessages = {
nav: { nav: {
home: 'Home', home: "Home",
title: 'Virtual Realm', title: "Virtual Realm",
directory: 'Directory', directory: "Directory",
faq: 'FAQ', faq: "FAQ",
events: 'Events', events: "Events",
market: 'Market', market: "Market",
chat: 'Chat', chat: "Chat",
login: 'Login', login: "Login",
logout: 'Logout' logout: "Logout",
}, },
common: { common: {
loading: 'Loading...', loading: "Loading...",
error: 'An error occurred', error: "An error occurred",
success: 'Operation successful', success: "Operation successful",
nav: { nav: {
profile: 'Profile', profile: "Profile",
preferences: 'Preferences', preferences: "Preferences",
profileDescription: 'Your Nostr identity and display name.', profileDescription: "Your Nostr identity and display name.",
profileLoggedOutDescription: 'Sign in or change your preferences.', profileLoggedOutDescription: "Sign in or change your preferences.",
login: 'Log in', login: "Log in",
menu: 'Menu', menu: "Menu",
backToHub: 'Back to hub', backToHub: "Back to hub",
hub: 'Hub', hub: "Hub",
theme: 'Theme', theme: "Theme",
themeLight: 'Light', themeLight: "Light",
themeDark: 'Dark', themeDark: "Dark",
themeSystem: 'System', themeSystem: "System",
colorScheme: 'Color scheme', colorScheme: "Color scheme",
palette_catppuccin: 'Catppuccin', palette_catppuccin: "Catppuccin",
palette_countrysidecastle: 'Countryside Castle', palette_countrysidecastle: "Countryside Castle",
palette_darkmatter: 'Dark Matter', palette_darkmatter: "Dark Matter",
palette_emeraldforest: 'Emerald Forest', palette_emeraldforest: "Emerald Forest",
palette_lightgreen: 'Light Green', palette_lightgreen: "Light Green",
palette_neobrut: 'Neo Brutalist', palette_neobrut: "Neo Brutalist",
palette_starrynight: 'Starry Night', palette_starrynight: "Starry Night",
language: 'Language', language: "Language",
currency: 'Currency', currency: "Currency",
currencyComingSoon: 'Currency picker — coming soon', currencyComingSoon: "Currency picker — coming soon",
currencyComingSoonDescription: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.' currencyComingSoonDescription:
} "A preferred-currency setting (sats/USD/EUR) is on the roadmap.",
},
}, },
errors: { errors: {
notFound: 'Page not found', notFound: "Page not found",
serverError: 'Server error occurred', serverError: "Server error occurred",
networkError: 'Network connection error' networkError: "Network connection error",
}, },
locales: { locales: {
en: 'English', en: "English",
es: 'Spanish', es: "Spanish",
fr: 'French', fr: "French",
de: 'German', de: "German",
zh: 'Chinese' zh: "Chinese",
}, },
events: { events: {
title: 'Events', title: "Events",
createNew: 'Create Event', createNew: "Create Event",
create: { create: {
promo: { promo: {
title: 'Promo codes', title: "Promo codes",
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.', hint: "Percentage discounts buyers can enter at checkout. Codes are never shown publicly.",
empty: 'No promo codes yet.', empty: "No promo codes yet.",
code: 'Code', code: "Code",
codePlaceholder: 'EARLYBIRD', codePlaceholder: "EARLYBIRD",
discount: 'Discount %', discount: "Discount %",
maxUses: 'Max uses', maxUses: "Max uses",
active: 'Active', active: "Active",
used: 'Used {count}', used: "Used {count}",
add: 'Add code', add: "Add code",
removeLabel: 'Remove code {code}', removeLabel: "Remove code {code}",
errors: { errors: {
required: 'Enter a code', required: "Enter a code",
duplicate: 'Duplicate code', duplicate: "Duplicate code",
percentRange: 'Between 1 and 100', percentRange: "Between 1 and 100",
maxUsesMin: 'Must be 1 or more', maxUsesMin: "Must be 1 or more",
}, },
}, },
}, },
noEvents: 'No events found', noEvents: "No events found",
filters: { filters: {
all: 'All', all: "All",
today: 'Today', today: "Today",
tomorrow: 'Tomorrow', tomorrow: "Tomorrow",
thisWeek: 'This Week', thisWeek: "This Week",
thisMonth: 'This Month', thisMonth: "This Month",
myTickets: 'My tickets', myTickets: "My tickets",
hosting: 'Hosting', hosting: "Hosting",
pastEvents: 'Past events', pastEvents: "Past events",
past: 'Past', past: "Past",
filters: 'Filters', filters: "Filters",
clearAll: 'Clear all', clearAll: "Clear all",
filteringBy: 'Filtering by:', filteringBy: "Filtering by:",
removeCategory: 'Remove {category} filter', removeCategory: "Remove {category} filter",
}, },
categories: { categories: {
concert: 'Concert', concert: "Concert",
workshop: 'Workshop', workshop: "Workshop",
market: 'Market', market: "Market",
festival: 'Festival', festival: "Festival",
exhibition: 'Exhibition', exhibition: "Exhibition",
sport: 'Sport', sport: "Sport",
theater: 'Theater', theater: "Theater",
cinema: 'Cinema', cinema: "Cinema",
party: 'Party', party: "Party",
talk: 'Talk', talk: "Talk",
conference: 'Conference', conference: "Conference",
meetup: 'Meetup', meetup: "Meetup",
food: 'Food', food: "Food",
outdoor: 'Outdoor', outdoor: "Outdoor",
kids: 'Kids', kids: "Kids",
wellness: 'Wellness', wellness: "Wellness",
technology: 'Technology', technology: "Technology",
art: 'Art', art: "Art",
music: 'Music', music: "Music",
dance: 'Dance', dance: "Dance",
literature: 'Literature', literature: "Literature",
comedy: 'Comedy', comedy: "Comedy",
charity: 'Charity', charity: "Charity",
tradition: 'Tradition', tradition: "Tradition",
other: 'Other', other: "Other",
}, },
detail: { detail: {
getTicket: 'Get Ticket', getTicket: "Get Ticket",
contactOrganizer: 'Contact Organizer', contactOrganizer: "Contact Organizer",
organizer: 'Organizer', organizer: "Organizer",
location: 'Location', location: "Location",
when: 'When', when: "When",
tickets: 'Tickets', tickets: "Tickets",
ticketsAvailable: '{count} tickets available', ticketsAvailable: "{count} tickets available",
ticketsRemainingOfTotal: '{count} of {total} tickets left', ticketsOwned: "You have {count} ticket | You have {count} tickets",
ticketsOwned: 'You have {count} ticket | You have {count} tickets', unlimitedTickets: "Unlimited tickets",
unlimitedTickets: 'Unlimited tickets', buyTicket: "Buy ticket",
buyTicket: 'Buy ticket', buyAnotherTicket: "Buy another ticket",
buyAnotherTicket: 'Buy another ticket', viewMyTickets: "View in My Tickets",
viewMyTickets: 'View in My Tickets', soldOut: "Sold Out",
soldOut: 'Sold Out', pastEvent: "This event has already happened",
pastEvent: 'This event has already happened', loginToBuyTickets: "Log in to buy tickets",
loginToBuyTickets: 'Log in to buy tickets', logIn: "Log in",
logIn: 'Log in', free: "Free",
free: 'Free',
}, },
tickets: { tickets: {
myTickets: 'My Tickets', myTickets: "My Tickets",
scanTicket: 'Scan Ticket', scanTicket: "Scan Ticket",
noTickets: 'No tickets yet', noTickets: "No tickets yet",
paid: 'Paid', paid: "Paid",
pending: 'Pending', pending: "Pending",
registered: 'Registered', registered: "Registered",
}, },
purchase: { purchase: {
card: 'Card', card: "Card",
continueTo: 'Continue to {provider} checkout', continueTo: "Continue to {provider} checkout",
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.", fiatRedirectHint:
noCheckoutUrl: 'Fiat provider did not return a checkout URL.', "You'll be taken to the provider's secure checkout and brought back here once paid.",
fiatFailed: 'Card checkout failed', noCheckoutUrl: "Fiat provider did not return a checkout URL.",
loginRequiredTitle: 'Login required', fiatFailed: "Card checkout failed",
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.', loginRequiredTitle: "Login required",
loginRequiredFree:
"Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.",
promo: { promo: {
label: 'Promo code', label: "Promo code",
placeholder: 'Enter code', placeholder: "Enter code",
apply: 'Apply', apply: "Apply",
remove: 'Remove', remove: "Remove",
checking: 'Checking…', checking: "Checking…",
applied: '{code} −{percent}%', applied: "{code} −{percent}%",
discountLine: 'Discount ({code})', discountLine: "Discount ({code})",
invalid: 'This code is not valid for this purchase.', invalid: "This code is not valid for this purchase.",
unverified: 'Could not verify the code — it will be applied at checkout if valid.', unverified:
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.', "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: { contact: {
title: 'Your details', title: "Your details",
name: 'Name', name: "Name",
namePlaceholder: 'Name on the ticket', namePlaceholder: "Name on the ticket",
nameRequired: 'Name is required', nameRequired: "Name is required",
email: 'Email', email: "Email",
emailHint: 'Your ticket will be sent to this address.', emailHint: "Your ticket will be sent to this address.",
spamHint: 'If it has not arrived a few minutes after paying, check your spam folder.', spamHint:
emailInvalid: 'Enter a valid email address', "If it has not arrived a few minutes after paying, check your spam folder.",
confirmEmail: 'Confirm email', emailInvalid: "Enter a valid email address",
emailMismatch: 'Email addresses do not match', confirmEmail: "Confirm email",
emailMismatch: "Email addresses do not match",
}, },
}, },
checkout: { checkout: {
title: 'Confirming your payment…', title: "Confirming your payment…",
paidTitle: 'Payment received', paidTitle: "Payment received",
processing: 'Waiting for the payment to settle. This usually takes a few seconds.', processing:
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.', "Waiting for the payment to settle. This usually takes a few seconds.",
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.', stillProcessing:
emailedTo: 'We emailed your ticket to {email}.', "Still processing. Your ticket will be emailed to {email} as soon as the payment settles.",
cancelled: 'Payment cancelled — no seats were reserved.', stillProcessingNoEmail:
viewTicket: 'Open ticket page', "Still processing. Reload this page in a moment to check again.",
viewMyTickets: 'View My Tickets', emailedTo: "We emailed your ticket to {email}.",
retry: 'Check again', cancelled: "Payment cancelled — no seats were reserved.",
ticketN: 'Ticket {n} of {total}', viewTicket: "Open ticket page",
close: 'Close', viewMyTickets: "View My Tickets",
retry: "Check again",
ticketN: "Ticket {n} of {total}",
close: "Close",
}, },
ticket: { ticket: {
title: 'Your ticket', title: "Your ticket",
loading: 'Loading ticket…', loading: "Loading ticket…",
notFound: 'Ticket not found', notFound: "Ticket not found",
event: 'Event', event: "Event",
holder: 'Name', holder: "Name",
paid: 'Paid', paid: "Paid",
pending: 'Payment pending', pending: "Payment pending",
registered: 'Checked in', registered: "Checked in",
showAtDoor: 'Show this QR at the door.', showAtDoor: "Show this QR at the door.",
backToEvent: 'Back to event', backToEvent: "Back to event",
}, },
nav: { nav: {
feed: 'Home', feed: "Home",
calendar: 'Calendar', calendar: "Calendar",
map: 'Map', map: "Map",
favorites: 'Favorites', favorites: "Favorites",
settings: 'Settings', settings: "Settings",
}, },
search: { search: {
placeholder: 'Search events...', placeholder: "Search events...",
noResults: 'No events found', noResults: "No events found",
}, },
favorites: { favorites: {
title: 'Favorites', title: "Favorites",
loginPrompt: 'Log in to save your favorite events', loginPrompt: "Log in to save your favorite events",
empty: 'No favorites yet', empty: "No favorites yet",
emptyHint: 'Tap the heart icon on any event to save it here', emptyHint: "Tap the heart icon on any event to save it here",
logIn: 'Log in', logIn: "Log in",
}, },
settings: { settings: {
title: 'Settings', title: "Settings",
account: 'Account', account: "Account",
loginPrompt: 'Log in to bookmark events, RSVP, and purchase tickets.', loginPrompt: "Log in to bookmark events, RSVP, and purchase tickets.",
logIn: 'Log in', logIn: "Log in",
logOut: 'Log out', logOut: "Log out",
appearance: 'Appearance', appearance: "Appearance",
theme: 'Theme', theme: "Theme",
language: 'Language', language: "Language",
}, },
}, },
libra: { libra: {
nav: { nav: {
record: 'Record', record: "Record",
transactions: 'Transactions', transactions: "Transactions",
balance: 'Balance', balance: "Balance",
wallet: 'Wallet', wallet: "Wallet",
settings: 'Settings', settings: "Settings",
}, },
record: { record: {
title: 'Record', title: "Record",
addExpense: 'Add Expense', addExpense: "Add Expense",
addExpenseDescription: 'Submit an expense for approval', addExpenseDescription: "Submit an expense for approval",
addIncome: 'Add Income', addIncome: "Add Income",
addIncomeDescription: 'Record income or revenue received', addIncomeDescription: "Record income or revenue received",
comingSoon: 'Coming Soon', comingSoon: "Coming Soon",
drafts: 'Drafts', drafts: "Drafts",
noDrafts: 'No saved drafts', noDrafts: "No saved drafts",
draftAge: 'Saved {time}', draftAge: "Saved {time}",
saveDraft: 'Save Draft', saveDraft: "Save Draft",
deleteDraft: 'Delete Draft', deleteDraft: "Delete Draft",
}, },
balance: { balance: {
title: 'Balance', title: "Balance",
netBalance: 'Net Balance', netBalance: "Net Balance",
pending: 'Pending', pending: "Pending",
pendingExpenses: '{count} pending expense | {count} pending expenses', pendingExpenses: "{count} pending expense | {count} pending expenses",
pendingAmount: '{amount} pending approval', pendingAmount: "{amount} pending approval",
noBalance: 'No balance data available', noBalance: "No balance data available",
owedToYou: 'Owed to you', owedToYou: "Owed to you",
youOwe: 'You owe', youOwe: "You owe",
}, },
settings: { settings: {
title: 'Settings', title: "Settings",
account: 'Account', account: "Account",
loginPrompt: 'Log in to record expenses and view your balance.', loginPrompt: "Log in to record expenses and view your balance.",
logIn: 'Log in', logIn: "Log in",
logOut: 'Log out', logOut: "Log out",
appearance: 'Appearance', appearance: "Appearance",
theme: 'Theme', theme: "Theme",
language: 'Language', language: "Language",
}, },
income: { income: {
title: 'Add Income', title: "Add Income",
description: 'Submit income for the organization', description: "Submit income for the organization",
selectAccount: 'Select the revenue account.', selectAccount: "Select the revenue account.",
otherAccountHint: 'Use the "Other" account if you\'re not sure.', otherAccountHint: 'Use the "Other" account if you\'re not sure.',
submitIncome: 'Submit Income', submitIncome: "Submit Income",
notAvailable: 'Income submission is not yet available. This feature is coming soon.', notAvailable:
"Income submission is not yet available. This feature is coming soon.",
}, },
}, },
market: { market: {
auth: { auth: {
loginPrompt: 'Log in to place your order', loginPrompt: "Log in to place your order",
logIn: 'Log in', logIn: "Log in",
logInToCheckout: 'Log in to checkout', logInToCheckout: "Log in to checkout",
nostrKeyRequired: 'A Nostr identity is required', nostrKeyRequired: "A Nostr identity is required",
nostrKeyDescription: 'Configure your Nostr public key in Profile settings to place orders.', nostrKeyDescription:
"Configure your Nostr public key in Profile settings to place orders.",
}, },
}, },
dateTimeFormats: { dateTimeFormats: {
short: { short: {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}, },
long: { long: {
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric', day: "numeric",
weekday: 'long', weekday: "long",
hour: 'numeric', hour: "numeric",
minute: 'numeric' minute: "numeric",
} },
}, },
numberFormats: { numberFormats: {
currency: { currency: {
style: 'currency', style: "currency",
currencyDisplay: 'symbol' currencyDisplay: "symbol",
}, },
decimal: { decimal: {
style: 'decimal', style: "decimal",
minimumFractionDigits: 2 minimumFractionDigits: 2,
}, },
percent: { percent: {
style: 'percent', style: "percent",
useGrouping: false useGrouping: false,
} },
} },
} };
export default messages export default messages;

View file

@ -1,331 +1,342 @@
import type { LocaleMessages } from '../types' import type { LocaleMessages } from "../types";
const messages: LocaleMessages = { const messages: LocaleMessages = {
nav: { nav: {
title: 'Reino Virtual', title: "Reino Virtual",
home: 'Inicio', home: "Inicio",
directory: 'Directorio', directory: "Directorio",
faq: 'Preguntas Frecuentes', faq: "Preguntas Frecuentes",
events: 'Eventos', events: "Eventos",
market: 'Mercado', market: "Mercado",
chat: 'Chat', chat: "Chat",
login: 'Iniciar Sesión', login: "Iniciar Sesión",
logout: 'Cerrar Sesión' logout: "Cerrar Sesión",
}, },
common: { common: {
loading: 'Cargando...', loading: "Cargando...",
error: 'Ha ocurrido un error', error: "Ha ocurrido un error",
success: 'Operación exitosa', success: "Operación exitosa",
nav: { nav: {
profile: 'Perfil', profile: "Perfil",
preferences: 'Preferencias', preferences: "Preferencias",
profileDescription: 'Tu identidad Nostr y nombre de visualización.', profileDescription: "Tu identidad Nostr y nombre de visualización.",
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.', profileLoggedOutDescription: "Inicia sesión o cambia tus preferencias.",
login: 'Iniciar sesión', login: "Iniciar sesión",
menu: 'Menú', menu: "Menú",
backToHub: 'Volver al hub', backToHub: "Volver al hub",
hub: 'Hub', hub: "Hub",
theme: 'Tema', theme: "Tema",
themeLight: 'Claro', themeLight: "Claro",
themeDark: 'Oscuro', themeDark: "Oscuro",
themeSystem: 'Sistema', themeSystem: "Sistema",
colorScheme: 'Paleta', colorScheme: "Paleta",
palette_catppuccin: 'Catppuccin', palette_catppuccin: "Catppuccin",
palette_countrysidecastle: 'Castillo Campestre', palette_countrysidecastle: "Castillo Campestre",
palette_darkmatter: 'Dark Matter', palette_darkmatter: "Dark Matter",
palette_emeraldforest: 'Bosque Esmeralda', palette_emeraldforest: "Bosque Esmeralda",
palette_lightgreen: 'Verde Claro', palette_lightgreen: "Verde Claro",
palette_neobrut: 'Neo Brutalista', palette_neobrut: "Neo Brutalista",
palette_starrynight: 'Noche Estrellada', palette_starrynight: "Noche Estrellada",
language: 'Idioma', language: "Idioma",
currency: 'Moneda', currency: "Moneda",
currencyComingSoon: 'Selector de moneda — próximamente', currencyComingSoon: "Selector de moneda — próximamente",
currencyComingSoonDescription: 'Un ajuste de moneda preferida (sats/USD/EUR) está en la hoja de ruta.' currencyComingSoonDescription:
} "Un ajuste de moneda preferida (sats/USD/EUR) está en la hoja de ruta.",
},
}, },
errors: { errors: {
notFound: 'Página no encontrada', notFound: "Página no encontrada",
serverError: 'Error del servidor', serverError: "Error del servidor",
networkError: 'Error de conexión de red' networkError: "Error de conexión de red",
}, },
locales: { locales: {
en: 'Inglés', en: "Inglés",
es: 'Español', es: "Español",
fr: 'Francés', fr: "Francés",
de: 'Alemán', de: "Alemán",
zh: 'Chino' zh: "Chino",
}, },
events: { events: {
title: 'Eventos', title: "Eventos",
createNew: 'Crear evento', createNew: "Crear evento",
create: { create: {
promo: { promo: {
title: 'Códigos promocionales', title: "Códigos promocionales",
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.', 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.', empty: "Aún no hay códigos promocionales.",
code: 'Código', code: "Código",
codePlaceholder: 'EARLYBIRD', codePlaceholder: "EARLYBIRD",
discount: 'Descuento %', discount: "Descuento %",
maxUses: 'Usos máx.', maxUses: "Usos máx.",
active: 'Activo', active: "Activo",
used: 'Usado {count}', used: "Usado {count}",
add: 'Añadir código', add: "Añadir código",
removeLabel: 'Eliminar el código {code}', removeLabel: "Eliminar el código {code}",
errors: { errors: {
required: 'Introduce un código', required: "Introduce un código",
duplicate: 'Código duplicado', duplicate: "Código duplicado",
percentRange: 'Entre 1 y 100', percentRange: "Entre 1 y 100",
maxUsesMin: 'Debe ser 1 o más', maxUsesMin: "Debe ser 1 o más",
}, },
}, },
}, },
noEvents: 'No se encontraron eventos', noEvents: "No se encontraron eventos",
filters: { filters: {
all: 'Todas', all: "Todas",
today: 'Hoy', today: "Hoy",
tomorrow: 'Mañana', tomorrow: "Mañana",
thisWeek: 'Esta semana', thisWeek: "Esta semana",
thisMonth: 'Este mes', thisMonth: "Este mes",
myTickets: 'Mis boletos', myTickets: "Mis boletos",
hosting: 'Organizo', hosting: "Organizo",
pastEvents: 'Eventos pasados', pastEvents: "Eventos pasados",
past: 'Pasado', past: "Pasado",
filters: 'Filtros', filters: "Filtros",
clearAll: 'Limpiar todo', clearAll: "Limpiar todo",
filteringBy: 'Filtrando por:', filteringBy: "Filtrando por:",
removeCategory: 'Quitar el filtro {category}', removeCategory: "Quitar el filtro {category}",
}, },
categories: { categories: {
concert: 'Concierto', concert: "Concierto",
workshop: 'Taller', workshop: "Taller",
market: 'Mercado', market: "Mercado",
festival: 'Festival', festival: "Festival",
exhibition: 'Exposición', exhibition: "Exposición",
sport: 'Deporte', sport: "Deporte",
theater: 'Teatro', theater: "Teatro",
cinema: 'Cine', cinema: "Cine",
party: 'Fiesta', party: "Fiesta",
talk: 'Charla', talk: "Charla",
conference: 'Conferencia', conference: "Conferencia",
meetup: 'Encuentro', meetup: "Encuentro",
food: 'Gastronomía', food: "Gastronomía",
outdoor: 'Al aire libre', outdoor: "Al aire libre",
kids: 'Niños', kids: "Niños",
wellness: 'Bienestar', wellness: "Bienestar",
technology: 'Tecnología', technology: "Tecnología",
art: 'Arte', art: "Arte",
music: 'Música', music: "Música",
dance: 'Danza', dance: "Danza",
literature: 'Literatura', literature: "Literatura",
comedy: 'Comedia', comedy: "Comedia",
charity: 'Solidario', charity: "Solidario",
tradition: 'Tradición', tradition: "Tradición",
other: 'Otro', other: "Otro",
}, },
detail: { detail: {
getTicket: 'Obtener boleto', getTicket: "Obtener boleto",
contactOrganizer: 'Contactar organizador', contactOrganizer: "Contactar organizador",
organizer: 'Organizador', organizer: "Organizador",
location: 'Ubicación', location: "Ubicación",
when: 'Cuándo', when: "Cuándo",
tickets: 'Boletos', tickets: "Boletos",
ticketsAvailable: '{count} boletos disponibles', ticketsAvailable: "{count} boletos disponibles",
ticketsRemainingOfTotal: '{count} de {total} boletos restantes', ticketsOwned: "Tienes {count} boleto | Tienes {count} boletos",
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos', unlimitedTickets: "Boletos ilimitados",
unlimitedTickets: 'Boletos ilimitados', buyTicket: "Comprar boleto",
buyTicket: 'Comprar boleto', buyAnotherTicket: "Comprar otro boleto",
buyAnotherTicket: 'Comprar otro boleto', viewMyTickets: "Ver en Mis boletos",
viewMyTickets: 'Ver en Mis boletos', soldOut: "Agotado",
soldOut: 'Agotado', pastEvent: "Este evento ya pasó",
pastEvent: 'Este evento ya pasó', loginToBuyTickets: "Inicia sesión para comprar boletos",
loginToBuyTickets: 'Inicia sesión para comprar boletos', logIn: "Iniciar sesión",
logIn: 'Iniciar sesión', free: "Gratis",
free: 'Gratis',
}, },
tickets: { tickets: {
myTickets: 'Mis boletos', myTickets: "Mis boletos",
scanTicket: 'Escanear boleto', scanTicket: "Escanear boleto",
noTickets: 'Aún no tienes boletos', noTickets: "Aún no tienes boletos",
paid: 'Pagado', paid: "Pagado",
pending: 'Pendiente', pending: "Pendiente",
registered: 'Registrado', registered: "Registrado",
}, },
purchase: { purchase: {
card: 'Tarjeta', card: "Tarjeta",
continueTo: 'Continuar al pago con {provider}', continueTo: "Continuar al pago con {provider}",
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.', fiatRedirectHint:
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.', "Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.",
fiatFailed: 'El pago con tarjeta falló', noCheckoutUrl: "El proveedor no devolvió una URL de pago.",
loginRequiredTitle: 'Inicio de sesión requerido', fiatFailed: "El pago con tarjeta falló",
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.', 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: { promo: {
label: 'Código promocional', label: "Código promocional",
placeholder: 'Introduce el código', placeholder: "Introduce el código",
apply: 'Aplicar', apply: "Aplicar",
remove: 'Quitar', remove: "Quitar",
checking: 'Comprobando…', checking: "Comprobando…",
applied: '{code} −{percent}%', applied: "{code} −{percent}%",
discountLine: 'Descuento ({code})', discountLine: "Descuento ({code})",
invalid: 'Este código no es válido para esta compra.', 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.', unverified:
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.', "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: { contact: {
title: 'Tus datos', title: "Tus datos",
name: 'Nombre', name: "Nombre",
namePlaceholder: 'Nombre en el boleto', namePlaceholder: "Nombre en el boleto",
nameRequired: 'El nombre es obligatorio', nameRequired: "El nombre es obligatorio",
email: 'Correo electrónico', email: "Correo electrónico",
emailHint: 'Tu boleto se enviará a esta dirección.', emailHint: "Tu boleto se enviará a esta dirección.",
spamHint: 'Si no llega unos minutos después de pagar, revisa tu carpeta de spam.', spamHint:
emailInvalid: 'Introduce un correo electrónico válido', "Si no llega unos minutos después de pagar, revisa tu carpeta de spam.",
confirmEmail: 'Confirmar correo', emailInvalid: "Introduce un correo electrónico válido",
emailMismatch: 'Los correos no coinciden', confirmEmail: "Confirmar correo",
emailMismatch: "Los correos no coinciden",
}, },
}, },
checkout: { checkout: {
title: 'Confirmando tu pago…', title: "Confirmando tu pago…",
paidTitle: 'Pago recibido', paidTitle: "Pago recibido",
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.', processing:
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.', "Esperando a que se liquide el pago. Suele tardar unos segundos.",
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.', stillProcessing:
emailedTo: 'Enviamos tu boleto a {email}.', "Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.",
cancelled: 'Pago cancelado — no se reservó ningún asiento.', stillProcessingNoEmail:
viewTicket: 'Abrir página del boleto', "Aún en proceso. Recarga esta página en un momento.",
viewMyTickets: 'Ver mis boletos', emailedTo: "Enviamos tu boleto a {email}.",
retry: 'Comprobar de nuevo', cancelled: "Pago cancelado — no se reservó ningún asiento.",
ticketN: 'Boleto {n} de {total}', viewTicket: "Abrir página del boleto",
close: 'Cerrar', viewMyTickets: "Ver mis boletos",
retry: "Comprobar de nuevo",
ticketN: "Boleto {n} de {total}",
close: "Cerrar",
}, },
ticket: { ticket: {
title: 'Tu boleto', title: "Tu boleto",
loading: 'Cargando boleto…', loading: "Cargando boleto…",
notFound: 'Boleto no encontrado', notFound: "Boleto no encontrado",
event: 'Evento', event: "Evento",
holder: 'Nombre', holder: "Nombre",
paid: 'Pagado', paid: "Pagado",
pending: 'Pago pendiente', pending: "Pago pendiente",
registered: 'Registrado', registered: "Registrado",
showAtDoor: 'Muestra este QR en la entrada.', showAtDoor: "Muestra este QR en la entrada.",
backToEvent: 'Volver al evento', backToEvent: "Volver al evento",
}, },
nav: { nav: {
feed: 'Inicio', feed: "Inicio",
calendar: 'Calendario', calendar: "Calendario",
map: 'Mapa', map: "Mapa",
favorites: 'Favoritos', favorites: "Favoritos",
settings: 'Ajustes', settings: "Ajustes",
}, },
search: { search: {
placeholder: 'Buscar eventos...', placeholder: "Buscar eventos...",
noResults: 'No se encontraron eventos', noResults: "No se encontraron eventos",
}, },
favorites: { favorites: {
title: 'Favoritos', title: "Favoritos",
loginPrompt: 'Inicia sesión para guardar tus eventos favoritos', loginPrompt: "Inicia sesión para guardar tus eventos favoritos",
empty: 'Aún no tienes favoritos', empty: "Aún no tienes favoritos",
emptyHint: 'Toca el corazón en cualquier evento para guardarlo aquí', emptyHint: "Toca el corazón en cualquier evento para guardarlo aquí",
logIn: 'Iniciar sesión', logIn: "Iniciar sesión",
}, },
settings: { settings: {
title: 'Ajustes', title: "Ajustes",
account: 'Cuenta', account: "Cuenta",
loginPrompt: 'Inicia sesión para guardar eventos, confirmar asistencia y comprar boletos.', loginPrompt:
logIn: 'Iniciar sesión', "Inicia sesión para guardar eventos, confirmar asistencia y comprar boletos.",
logOut: 'Cerrar sesión', logIn: "Iniciar sesión",
appearance: 'Apariencia', logOut: "Cerrar sesión",
theme: 'Tema', appearance: "Apariencia",
language: 'Idioma', theme: "Tema",
language: "Idioma",
}, },
}, },
libra: { libra: {
nav: { nav: {
record: 'Registrar', record: "Registrar",
transactions: 'Transacciones', transactions: "Transacciones",
balance: 'Saldo', balance: "Saldo",
wallet: 'Billetera', wallet: "Billetera",
settings: 'Ajustes', settings: "Ajustes",
}, },
record: { record: {
title: 'Registrar', title: "Registrar",
addExpense: 'A\u00f1adir gasto', addExpense: "A\u00f1adir gasto",
addExpenseDescription: 'Enviar un gasto para aprobaci\u00f3n', addExpenseDescription: "Enviar un gasto para aprobaci\u00f3n",
addIncome: 'A\u00f1adir ingreso', addIncome: "A\u00f1adir ingreso",
addIncomeDescription: 'Registrar un ingreso recibido', addIncomeDescription: "Registrar un ingreso recibido",
comingSoon: 'Pr\u00f3ximamente', comingSoon: "Pr\u00f3ximamente",
drafts: 'Borradores', drafts: "Borradores",
noDrafts: 'Sin borradores', noDrafts: "Sin borradores",
draftAge: 'Guardado {time}', draftAge: "Guardado {time}",
saveDraft: 'Guardar borrador', saveDraft: "Guardar borrador",
deleteDraft: 'Eliminar borrador', deleteDraft: "Eliminar borrador",
}, },
balance: { balance: {
title: 'Saldo', title: "Saldo",
netBalance: 'Saldo neto', netBalance: "Saldo neto",
pending: 'Pendiente', pending: "Pendiente",
pendingExpenses: '{count} gasto pendiente | {count} gastos pendientes', pendingExpenses: "{count} gasto pendiente | {count} gastos pendientes",
pendingAmount: '{amount} pendiente de aprobaci\u00f3n', pendingAmount: "{amount} pendiente de aprobaci\u00f3n",
noBalance: 'No hay datos de saldo disponibles', noBalance: "No hay datos de saldo disponibles",
owedToYou: 'Te deben', owedToYou: "Te deben",
youOwe: 'Debes', youOwe: "Debes",
}, },
settings: { settings: {
title: 'Ajustes', title: "Ajustes",
account: 'Cuenta', account: "Cuenta",
loginPrompt: 'Inicia sesi\u00f3n para registrar gastos y ver tu saldo.', loginPrompt: "Inicia sesi\u00f3n para registrar gastos y ver tu saldo.",
logIn: 'Iniciar sesi\u00f3n', logIn: "Iniciar sesi\u00f3n",
logOut: 'Cerrar sesi\u00f3n', logOut: "Cerrar sesi\u00f3n",
appearance: 'Apariencia', appearance: "Apariencia",
theme: 'Tema', theme: "Tema",
language: 'Idioma', language: "Idioma",
}, },
income: { income: {
title: 'A\u00f1adir ingreso', title: "A\u00f1adir ingreso",
description: 'Enviar un ingreso para la organizaci\u00f3n', description: "Enviar un ingreso para la organizaci\u00f3n",
selectAccount: 'Seleccionar la cuenta de ingresos.', selectAccount: "Seleccionar la cuenta de ingresos.",
otherAccountHint: 'Use la cuenta "Other" si no está seguro.', otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
submitIncome: 'Enviar ingreso', submitIncome: "Enviar ingreso",
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.', notAvailable:
"El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.",
}, },
}, },
market: { market: {
auth: { auth: {
loginPrompt: 'Inicia sesi\u00f3n para realizar tu pedido', loginPrompt: "Inicia sesi\u00f3n para realizar tu pedido",
logIn: 'Iniciar sesi\u00f3n', logIn: "Iniciar sesi\u00f3n",
logInToCheckout: 'Iniciar sesi\u00f3n para finalizar compra', logInToCheckout: "Iniciar sesi\u00f3n para finalizar compra",
nostrKeyRequired: 'Se requiere una identidad Nostr', nostrKeyRequired: "Se requiere una identidad Nostr",
nostrKeyDescription: 'Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.', nostrKeyDescription:
"Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.",
}, },
}, },
dateTimeFormats: { dateTimeFormats: {
short: { short: {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}, },
long: { long: {
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric', day: "numeric",
weekday: 'long', weekday: "long",
hour: 'numeric', hour: "numeric",
minute: 'numeric' minute: "numeric",
} },
}, },
numberFormats: { numberFormats: {
currency: { currency: {
style: 'currency', style: "currency",
currencyDisplay: 'symbol' currencyDisplay: "symbol",
}, },
decimal: { decimal: {
style: 'decimal', style: "decimal",
minimumFractionDigits: 2 minimumFractionDigits: 2,
}, },
percent: { percent: {
style: 'percent', style: "percent",
useGrouping: false useGrouping: false,
} },
} },
} };
export default messages export default messages;

View file

@ -1,331 +1,346 @@
import type { LocaleMessages } from '../types' import type { LocaleMessages } from "../types";
const messages: LocaleMessages = { const messages: LocaleMessages = {
nav: { nav: {
title: 'Monde Virtuel', title: "Monde Virtuel",
home: 'Accueil', home: "Accueil",
directory: 'Répertoire', directory: "Répertoire",
faq: 'FAQ', faq: "FAQ",
events: 'Événements', events: "Événements",
market: 'Marché', market: "Marché",
chat: 'Chat', chat: "Chat",
login: 'Connexion', login: "Connexion",
logout: 'Déconnexion' logout: "Déconnexion",
}, },
common: { common: {
loading: 'Chargement...', loading: "Chargement...",
error: 'Une erreur est survenue', error: "Une erreur est survenue",
success: 'Opération réussie', success: "Opération réussie",
nav: { nav: {
profile: 'Profil', profile: "Profil",
preferences: 'Préférences', preferences: "Préférences",
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.', profileDescription: "Votre identité Nostr et votre nom d\u2019affichage.",
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.', profileLoggedOutDescription:
login: 'Se connecter', "Connectez-vous ou modifiez vos préférences.",
menu: 'Menu', login: "Se connecter",
backToHub: 'Retour au hub', menu: "Menu",
hub: 'Hub', backToHub: "Retour au hub",
theme: 'Thème', hub: "Hub",
themeLight: 'Clair', theme: "Thème",
themeDark: 'Sombre', themeLight: "Clair",
themeSystem: 'Système', themeDark: "Sombre",
colorScheme: 'Palette', themeSystem: "Système",
palette_catppuccin: 'Catppuccin', colorScheme: "Palette",
palette_countrysidecastle: 'Château Champêtre', palette_catppuccin: "Catppuccin",
palette_darkmatter: 'Dark Matter', palette_countrysidecastle: "Château Champêtre",
palette_emeraldforest: 'Forêt d\'Émeraude', palette_darkmatter: "Dark Matter",
palette_lightgreen: 'Vert Clair', palette_emeraldforest: "Forêt d'Émeraude",
palette_neobrut: 'Néo-Brutaliste', palette_lightgreen: "Vert Clair",
palette_starrynight: 'Nuit Étoilée', palette_neobrut: "Néo-Brutaliste",
language: 'Langue', palette_starrynight: "Nuit Étoilée",
currency: 'Devise', language: "Langue",
currencyComingSoon: 'Sélecteur de devise — bientôt disponible', currency: "Devise",
currencyComingSoonDescription: 'Un réglage de devise préférée (sats/USD/EUR) est prévu.' currencyComingSoon: "Sélecteur de devise — bientôt disponible",
} currencyComingSoonDescription:
"Un réglage de devise préférée (sats/USD/EUR) est prévu.",
},
}, },
errors: { errors: {
notFound: 'Page non trouvée', notFound: "Page non trouvée",
serverError: 'Erreur du serveur', serverError: "Erreur du serveur",
networkError: 'Erreur de connexion réseau' networkError: "Erreur de connexion réseau",
}, },
locales: { locales: {
en: 'Anglais', en: "Anglais",
es: 'Espagnol', es: "Espagnol",
fr: 'Français', fr: "Français",
de: 'Allemand', de: "Allemand",
zh: 'Chinois' zh: "Chinois",
}, },
events: { events: {
title: 'Événements', title: "Événements",
createNew: 'Créer un événement', createNew: "Créer un événement",
create: { create: {
promo: { promo: {
title: 'Codes promo', title: "Codes promo",
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.', 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.', empty: "Aucun code promo pour le moment.",
code: 'Code', code: "Code",
codePlaceholder: 'EARLYBIRD', codePlaceholder: "EARLYBIRD",
discount: 'Remise %', discount: "Remise %",
maxUses: 'Utilisations max.', maxUses: "Utilisations max.",
active: 'Actif', active: "Actif",
used: 'Utilisé {count}', used: "Utilisé {count}",
add: 'Ajouter un code', add: "Ajouter un code",
removeLabel: 'Supprimer le code {code}', removeLabel: "Supprimer le code {code}",
errors: { errors: {
required: 'Saisissez un code', required: "Saisissez un code",
duplicate: 'Code en double', duplicate: "Code en double",
percentRange: 'Entre 1 et 100', percentRange: "Entre 1 et 100",
maxUsesMin: 'Doit être 1 ou plus', maxUsesMin: "Doit être 1 ou plus",
}, },
}, },
}, },
noEvents: 'Aucun événement trouvé', noEvents: "Aucun événement trouvé",
filters: { filters: {
all: 'Tout', all: "Tout",
today: "Aujourd'hui", today: "Aujourd'hui",
tomorrow: 'Demain', tomorrow: "Demain",
thisWeek: 'Cette semaine', thisWeek: "Cette semaine",
thisMonth: 'Ce mois-ci', thisMonth: "Ce mois-ci",
myTickets: 'Mes billets', myTickets: "Mes billets",
hosting: 'J\'organise', hosting: "J'organise",
pastEvents: 'Événements passés', pastEvents: "Événements passés",
past: 'Passé', past: "Passé",
filters: 'Filtres', filters: "Filtres",
clearAll: 'Tout effacer', clearAll: "Tout effacer",
filteringBy: 'Filtré par :', filteringBy: "Filtré par :",
removeCategory: 'Retirer le filtre {category}', removeCategory: "Retirer le filtre {category}",
}, },
categories: { categories: {
concert: 'Concert', concert: "Concert",
workshop: 'Atelier', workshop: "Atelier",
market: 'Marché', market: "Marché",
festival: 'Festival', festival: "Festival",
exhibition: 'Exposition', exhibition: "Exposition",
sport: 'Sport', sport: "Sport",
theater: 'Théâtre', theater: "Théâtre",
cinema: 'Cinéma', cinema: "Cinéma",
party: 'Fête', party: "Fête",
talk: 'Conférence', talk: "Conférence",
conference: 'Congrès', conference: "Congrès",
meetup: 'Rencontre', meetup: "Rencontre",
food: 'Gastronomie', food: "Gastronomie",
outdoor: 'Plein air', outdoor: "Plein air",
kids: 'Enfants', kids: "Enfants",
wellness: 'Bien-être', wellness: "Bien-être",
technology: 'Technologie', technology: "Technologie",
art: 'Art', art: "Art",
music: 'Musique', music: "Musique",
dance: 'Danse', dance: "Danse",
literature: 'Littérature', literature: "Littérature",
comedy: 'Humour', comedy: "Humour",
charity: 'Caritatif', charity: "Caritatif",
tradition: 'Tradition', tradition: "Tradition",
other: 'Autre', other: "Autre",
}, },
detail: { detail: {
getTicket: 'Obtenir un billet', getTicket: "Obtenir un billet",
contactOrganizer: "Contacter l'organisateur", contactOrganizer: "Contacter l'organisateur",
organizer: 'Organisateur', organizer: "Organisateur",
location: 'Lieu', location: "Lieu",
when: 'Quand', when: "Quand",
tickets: 'Billets', tickets: "Billets",
ticketsAvailable: '{count} billets disponibles', ticketsAvailable: "{count} billets disponibles",
ticketsRemainingOfTotal: '{count} sur {total} billets restants', ticketsOwned: "Vous avez {count} billet | Vous avez {count} billets",
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets', unlimitedTickets: "Billets illimités",
unlimitedTickets: 'Billets illimités', buyTicket: "Acheter un billet",
buyTicket: 'Acheter un billet', buyAnotherTicket: "Acheter un autre billet",
buyAnotherTicket: 'Acheter un autre billet', viewMyTickets: "Voir dans Mes billets",
viewMyTickets: 'Voir dans Mes billets', soldOut: "Épuisé",
soldOut: 'Épuisé', pastEvent: "Cet événement est déjà passé",
pastEvent: 'Cet événement est déjà passé', loginToBuyTickets: "Connectez-vous pour acheter des billets",
loginToBuyTickets: 'Connectez-vous pour acheter des billets', logIn: "Connexion",
logIn: 'Connexion', free: "Gratuit",
free: 'Gratuit',
}, },
tickets: { tickets: {
myTickets: 'Mes billets', myTickets: "Mes billets",
scanTicket: 'Scanner le billet', scanTicket: "Scanner le billet",
noTickets: 'Pas encore de billets', noTickets: "Pas encore de billets",
paid: 'Payé', paid: "Payé",
pending: 'En attente', pending: "En attente",
registered: 'Enregistré', registered: "Enregistré",
}, },
purchase: { purchase: {
card: 'Carte', card: "Carte",
continueTo: 'Continuer vers le paiement {provider}', continueTo: "Continuer vers le paiement {provider}",
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.', fiatRedirectHint:
"Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.",
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.", noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
fiatFailed: 'Le paiement par carte a échoué', fiatFailed: "Le paiement par carte a échoué",
loginRequiredTitle: 'Connexion requise', loginRequiredTitle: "Connexion requise",
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.', loginRequiredFree:
"Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.",
promo: { promo: {
label: 'Code promo', label: "Code promo",
placeholder: 'Saisir le code', placeholder: "Saisir le code",
apply: 'Appliquer', apply: "Appliquer",
remove: 'Retirer', remove: "Retirer",
checking: 'Vérification…', checking: "Vérification…",
applied: '{code} −{percent}%', applied: "{code} −{percent}%",
discountLine: 'Remise ({code})', discountLine: "Remise ({code})",
invalid: "Ce code n'est pas valable pour cet achat.", 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.", unverified:
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.', "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: { contact: {
title: 'Vos coordonnées', title: "Vos coordonnées",
name: 'Nom', name: "Nom",
namePlaceholder: 'Nom sur le billet', namePlaceholder: "Nom sur le billet",
nameRequired: 'Le nom est obligatoire', nameRequired: "Le nom est obligatoire",
email: 'E-mail', email: "E-mail",
emailHint: 'Votre billet sera envoyé à cette adresse.', emailHint: "Votre billet sera envoyé à cette adresse.",
spamHint: 'S’il n’est pas arrivé quelques minutes après le paiement, vérifiez votre dossier spam.', spamHint:
emailInvalid: 'Saisissez une adresse e-mail valide', "S’il n’est pas arrivé quelques minutes après le paiement, vérifiez votre dossier spam.",
emailInvalid: "Saisissez une adresse e-mail valide",
confirmEmail: "Confirmer l'e-mail", confirmEmail: "Confirmer l'e-mail",
emailMismatch: 'Les adresses e-mail ne correspondent pas', emailMismatch: "Les adresses e-mail ne correspondent pas",
}, },
}, },
checkout: { checkout: {
title: 'Confirmation de votre paiement…', title: "Confirmation de votre paiement…",
paidTitle: 'Paiement reçu', paidTitle: "Paiement reçu",
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.', processing:
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.', "En attente du règlement du paiement. Cela prend généralement quelques secondes.",
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.', stillProcessing:
emailedTo: 'Nous avons envoyé votre billet à {email}.', "Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.",
cancelled: 'Paiement annulé — aucune place réservée.', stillProcessingNoEmail:
viewTicket: 'Ouvrir la page du billet', "Toujours en cours. Rechargez cette page dans un instant.",
viewMyTickets: 'Voir mes billets', emailedTo: "Nous avons envoyé votre billet à {email}.",
retry: 'Vérifier à nouveau', cancelled: "Paiement annulé — aucune place réservée.",
ticketN: 'Billet {n} sur {total}', viewTicket: "Ouvrir la page du billet",
close: 'Fermer', viewMyTickets: "Voir mes billets",
retry: "Vérifier à nouveau",
ticketN: "Billet {n} sur {total}",
close: "Fermer",
}, },
ticket: { ticket: {
title: 'Votre billet', title: "Votre billet",
loading: 'Chargement du billet…', loading: "Chargement du billet…",
notFound: 'Billet introuvable', notFound: "Billet introuvable",
event: 'Événement', event: "Événement",
holder: 'Nom', holder: "Nom",
paid: 'Payé', paid: "Payé",
pending: 'Paiement en attente', pending: "Paiement en attente",
registered: 'Enregistré', registered: "Enregistré",
showAtDoor: "Présentez ce QR à l'entrée.", showAtDoor: "Présentez ce QR à l'entrée.",
backToEvent: "Retour à l'événement", backToEvent: "Retour à l'événement",
}, },
nav: { nav: {
feed: 'Accueil', feed: "Accueil",
calendar: 'Calendrier', calendar: "Calendrier",
map: 'Carte', map: "Carte",
favorites: 'Favoris', favorites: "Favoris",
settings: 'Réglages', settings: "Réglages",
}, },
search: { search: {
placeholder: 'Rechercher des événements...', placeholder: "Rechercher des événements...",
noResults: 'Aucun événement trouvé', noResults: "Aucun événement trouvé",
}, },
favorites: { favorites: {
title: 'Favoris', title: "Favoris",
loginPrompt: 'Connectez-vous pour sauvegarder vos événements préférés', loginPrompt: "Connectez-vous pour sauvegarder vos événements préférés",
empty: 'Pas encore de favoris', empty: "Pas encore de favoris",
emptyHint: "Appuyez sur le cœur d'un événement pour le sauvegarder ici", emptyHint: "Appuyez sur le cœur d'un événement pour le sauvegarder ici",
logIn: 'Se connecter', logIn: "Se connecter",
}, },
settings: { settings: {
title: 'Réglages', title: "Réglages",
account: 'Compte', account: "Compte",
loginPrompt: 'Connectez-vous pour sauvegarder des événements, confirmer votre présence et acheter des billets.', loginPrompt:
logIn: 'Se connecter', "Connectez-vous pour sauvegarder des événements, confirmer votre présence et acheter des billets.",
logOut: 'Se déconnecter', logIn: "Se connecter",
appearance: 'Apparence', logOut: "Se déconnecter",
theme: 'Thème', appearance: "Apparence",
language: 'Langue', theme: "Thème",
language: "Langue",
}, },
}, },
libra: { libra: {
nav: { nav: {
record: 'Saisir', record: "Saisir",
transactions: 'Transactions', transactions: "Transactions",
balance: 'Solde', balance: "Solde",
wallet: 'Portefeuille', wallet: "Portefeuille",
settings: 'Param\u00e8tres', settings: "Param\u00e8tres",
}, },
record: { record: {
title: 'Saisir', title: "Saisir",
addExpense: 'Ajouter une d\u00e9pense', addExpense: "Ajouter une d\u00e9pense",
addExpenseDescription: 'Soumettre une d\u00e9pense pour approbation', addExpenseDescription: "Soumettre une d\u00e9pense pour approbation",
addIncome: 'Ajouter un revenu', addIncome: "Ajouter un revenu",
addIncomeDescription: 'Enregistrer un revenu re\u00e7u', addIncomeDescription: "Enregistrer un revenu re\u00e7u",
comingSoon: 'Bient\u00f4t disponible', comingSoon: "Bient\u00f4t disponible",
drafts: 'Brouillons', drafts: "Brouillons",
noDrafts: 'Aucun brouillon', noDrafts: "Aucun brouillon",
draftAge: 'Enregistr\u00e9 {time}', draftAge: "Enregistr\u00e9 {time}",
saveDraft: 'Enregistrer le brouillon', saveDraft: "Enregistrer le brouillon",
deleteDraft: 'Supprimer le brouillon', deleteDraft: "Supprimer le brouillon",
}, },
balance: { balance: {
title: 'Solde', title: "Solde",
netBalance: 'Solde net', netBalance: "Solde net",
pending: 'En attente', pending: "En attente",
pendingExpenses: '{count} d\u00e9pense en attente | {count} d\u00e9penses en attente', pendingExpenses:
pendingAmount: '{amount} en attente d\u2019approbation', "{count} d\u00e9pense en attente | {count} d\u00e9penses en attente",
noBalance: 'Aucune donn\u00e9e de solde disponible', pendingAmount: "{amount} en attente d\u2019approbation",
owedToYou: 'D\u00fb', noBalance: "Aucune donn\u00e9e de solde disponible",
youOwe: 'Vous devez', owedToYou: "D\u00fb",
youOwe: "Vous devez",
}, },
settings: { settings: {
title: 'Param\u00e8tres', title: "Param\u00e8tres",
account: 'Compte', account: "Compte",
loginPrompt: 'Connectez-vous pour saisir des d\u00e9penses et voir votre solde.', loginPrompt:
logIn: 'Se connecter', "Connectez-vous pour saisir des d\u00e9penses et voir votre solde.",
logOut: 'Se d\u00e9connecter', logIn: "Se connecter",
appearance: 'Apparence', logOut: "Se d\u00e9connecter",
theme: 'Th\u00e8me', appearance: "Apparence",
language: 'Langue', theme: "Th\u00e8me",
language: "Langue",
}, },
income: { income: {
title: 'Ajouter un revenu', title: "Ajouter un revenu",
description: 'Soumettre un revenu pour l\u2019organisation', description: "Soumettre un revenu pour l\u2019organisation",
selectAccount: 'S\u00e9lectionner le compte de revenus.', selectAccount: "S\u00e9lectionner le compte de revenus.",
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.', otherAccountHint:
submitIncome: 'Soumettre le revenu', 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.', submitIncome: "Soumettre le revenu",
notAvailable:
"La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.",
}, },
}, },
market: { market: {
auth: { auth: {
loginPrompt: 'Connectez-vous pour passer commande', loginPrompt: "Connectez-vous pour passer commande",
logIn: 'Se connecter', logIn: "Se connecter",
logInToCheckout: 'Se connecter pour commander', logInToCheckout: "Se connecter pour commander",
nostrKeyRequired: 'Une identit\u00e9 Nostr est requise', nostrKeyRequired: "Une identit\u00e9 Nostr est requise",
nostrKeyDescription: 'Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.', nostrKeyDescription:
"Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.",
}, },
}, },
dateTimeFormats: { dateTimeFormats: {
short: { short: {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}, },
long: { long: {
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric', day: "numeric",
weekday: 'long', weekday: "long",
hour: 'numeric', hour: "numeric",
minute: 'numeric' minute: "numeric",
} },
}, },
numberFormats: { numberFormats: {
currency: { currency: {
style: 'currency', style: "currency",
currencyDisplay: 'symbol' currencyDisplay: "symbol",
}, },
decimal: { decimal: {
style: 'decimal', style: "decimal",
minimumFractionDigits: 2 minimumFractionDigits: 2,
}, },
percent: { percent: {
style: 'percent', style: "percent",
useGrouping: false useGrouping: false,
} },
} },
} };
export default messages export default messages;

View file

@ -1,308 +1,307 @@
export interface LocaleMessages { export interface LocaleMessages {
nav: { nav: {
title: string title: string;
home: string home: string;
directory: string directory: string;
faq: string faq: string;
events: string events: string;
market: string market: string;
chat: string chat: string;
login: string login: string;
logout: string logout: string;
} };
// Add more message categories here // Add more message categories here
common: { common: {
loading: string loading: string;
error: string error: string;
success: string success: string;
nav: { nav: {
profile: string profile: string;
preferences: string preferences: string;
profileDescription: string profileDescription: string;
profileLoggedOutDescription: string profileLoggedOutDescription: string;
login: string login: string;
menu: string menu: string;
backToHub: string backToHub: string;
hub: string hub: string;
theme: string theme: string;
themeLight: string themeLight: string;
themeDark: string themeDark: string;
themeSystem: string themeSystem: string;
colorScheme: string colorScheme: string;
palette_catppuccin: string palette_catppuccin: string;
palette_countrysidecastle: string palette_countrysidecastle: string;
palette_darkmatter: string palette_darkmatter: string;
palette_emeraldforest: string palette_emeraldforest: string;
palette_lightgreen: string palette_lightgreen: string;
palette_neobrut: string palette_neobrut: string;
palette_starrynight: string palette_starrynight: string;
language: string language: string;
currency: string currency: string;
currencyComingSoon: string currencyComingSoon: string;
currencyComingSoonDescription: string currencyComingSoonDescription: string;
} };
} };
errors: { errors: {
notFound: string notFound: string;
serverError: string serverError: string;
networkError: string networkError: string;
} };
// Language names in the current language // Language names in the current language
locales: { locales: {
en: string en: string;
es: string es: string;
fr: string fr: string;
de: string de: string;
zh: string zh: string;
} };
// Events module // Events module
events?: { events?: {
title: string title: string;
createNew: string createNew: string;
create: { create: {
promo: { promo: {
title: string title: string;
hint: string hint: string;
empty: string empty: string;
code: string code: string;
codePlaceholder: string codePlaceholder: string;
discount: string discount: string;
maxUses: string maxUses: string;
active: string active: string;
used: string used: string;
add: string add: string;
removeLabel: string removeLabel: string;
errors: { errors: {
required: string required: string;
duplicate: string duplicate: string;
percentRange: string percentRange: string;
maxUsesMin: string maxUsesMin: string;
} };
} };
} };
noEvents: string noEvents: string;
filters: { filters: {
all: string all: string;
today: string today: string;
tomorrow: string tomorrow: string;
thisWeek: string thisWeek: string;
thisMonth: string thisMonth: string;
myTickets: string myTickets: string;
hosting: string hosting: string;
pastEvents: string pastEvents: string;
past: string past: string;
filters: string filters: string;
clearAll: string clearAll: string;
filteringBy: string filteringBy: string;
removeCategory: string removeCategory: string;
} };
categories: Record<string, string> categories: Record<string, string>;
detail: { detail: {
getTicket: string getTicket: string;
contactOrganizer: string contactOrganizer: string;
organizer: string organizer: string;
location: string location: string;
when: string when: string;
tickets: string tickets: string;
ticketsAvailable: string ticketsAvailable: string;
ticketsRemainingOfTotal: string ticketsOwned: string;
ticketsOwned: string unlimitedTickets: string;
unlimitedTickets: string buyTicket: string;
buyTicket: string buyAnotherTicket: string;
buyAnotherTicket: string viewMyTickets: string;
viewMyTickets: string soldOut: string;
soldOut: string pastEvent: string;
pastEvent: string loginToBuyTickets: string;
loginToBuyTickets: string logIn: string;
logIn: string free: string;
free: string };
}
tickets: { tickets: {
myTickets: string myTickets: string;
scanTicket: string scanTicket: string;
noTickets: string noTickets: string;
paid: string paid: string;
pending: string pending: string;
registered: string registered: string;
} };
purchase: { purchase: {
card: string card: string;
continueTo: string continueTo: string;
fiatRedirectHint: string fiatRedirectHint: string;
noCheckoutUrl: string noCheckoutUrl: string;
fiatFailed: string fiatFailed: string;
loginRequiredTitle: string loginRequiredTitle: string;
loginRequiredFree: string loginRequiredFree: string;
promo: { promo: {
label: string label: string;
placeholder: string placeholder: string;
apply: string apply: string;
remove: string remove: string;
checking: string checking: string;
applied: string applied: string;
discountLine: string discountLine: string;
invalid: string invalid: string;
unverified: string unverified: string;
loginRequiredDiscount: string loginRequiredDiscount: string;
} };
contact: { contact: {
title: string title: string;
name: string name: string;
namePlaceholder: string namePlaceholder: string;
nameRequired: string nameRequired: string;
email: string email: string;
emailHint: string emailHint: string;
spamHint: string spamHint: string;
emailInvalid: string emailInvalid: string;
confirmEmail: string confirmEmail: string;
emailMismatch: string emailMismatch: string;
} };
} };
checkout: { checkout: {
title: string title: string;
paidTitle: string paidTitle: string;
processing: string processing: string;
stillProcessing: string stillProcessing: string;
stillProcessingNoEmail: string stillProcessingNoEmail: string;
emailedTo: string emailedTo: string;
cancelled: string cancelled: string;
viewTicket: string viewTicket: string;
viewMyTickets: string viewMyTickets: string;
retry: string retry: string;
ticketN: string ticketN: string;
close: string close: string;
} };
ticket: { ticket: {
title: string title: string;
loading: string loading: string;
notFound: string notFound: string;
event: string event: string;
holder: string holder: string;
paid: string paid: string;
pending: string pending: string;
registered: string registered: string;
showAtDoor: string showAtDoor: string;
backToEvent: string backToEvent: string;
} };
nav: { nav: {
feed: string feed: string;
calendar: string calendar: string;
map: string map: string;
favorites: string favorites: string;
settings: string settings: string;
} };
search: { search: {
placeholder: string placeholder: string;
noResults: string noResults: string;
} };
favorites: { favorites: {
title: string title: string;
loginPrompt: string loginPrompt: string;
empty: string empty: string;
emptyHint: string emptyHint: string;
logIn: string logIn: string;
} };
settings: { settings: {
title: string title: string;
account: string account: string;
loginPrompt: string loginPrompt: string;
logIn: string logIn: string;
logOut: string logOut: string;
appearance: string appearance: string;
theme: string theme: string;
language: string language: string;
} };
} };
// Libra accounting module // Libra accounting module
libra?: { libra?: {
nav: { nav: {
record: string record: string;
transactions: string transactions: string;
balance: string balance: string;
wallet: string wallet: string;
settings: string settings: string;
} };
record: { record: {
title: string title: string;
addExpense: string addExpense: string;
addExpenseDescription: string addExpenseDescription: string;
addIncome: string addIncome: string;
addIncomeDescription: string addIncomeDescription: string;
comingSoon: string comingSoon: string;
drafts: string drafts: string;
noDrafts: string noDrafts: string;
draftAge: string draftAge: string;
saveDraft: string saveDraft: string;
deleteDraft: string deleteDraft: string;
} };
balance: { balance: {
title: string title: string;
netBalance: string netBalance: string;
pending: string pending: string;
pendingExpenses: string pendingExpenses: string;
pendingAmount: string pendingAmount: string;
noBalance: string noBalance: string;
owedToYou: string owedToYou: string;
youOwe: string youOwe: string;
} };
settings: { settings: {
title: string title: string;
account: string account: string;
loginPrompt: string loginPrompt: string;
logIn: string logIn: string;
logOut: string logOut: string;
appearance: string appearance: string;
theme: string theme: string;
language: string language: string;
} };
income: { income: {
title: string title: string;
description: string description: string;
selectAccount: string selectAccount: string;
otherAccountHint: string otherAccountHint: string;
submitIncome: string submitIncome: string;
notAvailable: string notAvailable: string;
} };
} };
// Market module // Market module
market?: { market?: {
auth: { auth: {
loginPrompt: string loginPrompt: string;
logIn: string logIn: string;
logInToCheckout: string logInToCheckout: string;
nostrKeyRequired: string nostrKeyRequired: string;
nostrKeyDescription: string nostrKeyDescription: string;
} };
} };
// Add date/time formats // Add date/time formats
dateTimeFormats: { dateTimeFormats: {
short: { short: {
year: 'numeric' year: "numeric";
month: 'short' month: "short";
day: 'numeric' day: "numeric";
} };
long: { long: {
year: 'numeric' year: "numeric";
month: 'long' month: "long";
day: 'numeric' day: "numeric";
weekday: 'long' weekday: "long";
hour: 'numeric' hour: "numeric";
minute: 'numeric' minute: "numeric";
} };
} };
// Add number formats // Add number formats
numberFormats: { numberFormats: {
currency: { currency: {
style: 'currency' style: "currency";
currencyDisplay: 'symbol' currencyDisplay: "symbol";
} };
decimal: { decimal: {
style: 'decimal' style: "decimal";
minimumFractionDigits: 2 minimumFractionDigits: 2;
} };
percent: { percent: {
style: 'percent' style: "percent";
useGrouping: false useGrouping: false;
} };
} };
} }

File diff suppressed because it is too large Load diff

View file

@ -1,74 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from "vue";
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
import { format } from 'date-fns' import { format } from "date-fns";
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from "@/components/ui/card";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next' import {
import BookmarkButton from './BookmarkButton.vue' MapPin,
import OrganizerCard from './OrganizerCard.vue' Calendar,
import { useDateLocale } from '../composables/useDateLocale' Ticket,
import { useOwnedTickets } from '../composables/useOwnedTickets' User,
import type { Event } from '../types/event' CheckCircle2,
History,
} from "lucide-vue-next";
import BookmarkButton from "./BookmarkButton.vue";
import OrganizerCard from "./OrganizerCard.vue";
import { useDateLocale } from "../composables/useDateLocale";
import { useOwnedTickets } from "../composables/useOwnedTickets";
import type { Event } from "../types/event";
const props = defineProps<{ const props = defineProps<{
event: Event event: Event;
/** Render a compact row: no hero image, no summary, single-line /** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the * title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */ * host already knows what their events look like. */
compact?: boolean compact?: boolean;
}>() }>();
const emit = defineEmits<{ const emit = defineEmits<{
click: [event: Event] click: [event: Event];
}>() }>();
const { t } = useI18n() const { t } = useI18n();
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale();
const { paidCount } = useOwnedTickets() const { paidCount } = useOwnedTickets();
const ownedCount = computed(() => paidCount(props.event.id)) const ownedCount = computed(() => paidCount(props.event.id));
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.event const a = props.event;
if (!a.startDate || isNaN(a.startDate.getTime())) return '' if (!a.startDate || isNaN(a.startDate.getTime())) return "";
try { try {
const opts = { locale: dateLocale.value } const opts = { locale: dateLocale.value };
if (a.type === 'date') { if (a.type === "date") {
return format(a.startDate, 'EEE, MMM d', opts) return format(a.startDate, "EEE, MMM d", opts);
} }
const date = format(a.startDate, 'EEE, MMM d', opts) const date = format(a.startDate, "EEE, MMM d", opts);
const time = format(a.startDate, 'HH:mm', opts) const time = format(a.startDate, "HH:mm", opts);
return `${date} \u2022 ${time}` return `${date} \u2022 ${time}`;
} catch { } catch {
return '' return "";
} }
}) });
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
if (!props.event.category) return null if (!props.event.category) return null;
return t(`events.categories.${props.event.category}`, props.event.category) return t(`events.categories.${props.event.category}`, props.event.category);
}) });
const priceDisplay = computed(() => { const priceDisplay = computed(() => {
const info = props.event.ticketInfo const info = props.event.ticketInfo;
if (!info) return null if (!info) return null;
if (info.price === 0) return t('events.detail.free') if (info.price === 0) return t("events.detail.free");
return `${info.price} ${info.currency}` return `${info.price} ${info.currency}`;
}) });
const isPast = computed(() => { const isPast = computed(() => {
const a = props.event const a = props.event;
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate;
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false;
return end.getTime() < Date.now() return end.getTime() < Date.now();
}) });
// Pending / rejected events get a washed-out look so the user // Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge. // sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed( const isNonApproved = computed(
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved', () => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== "approved",
) );
</script> </script>
<template> <template>
@ -92,187 +99,210 @@ const isNonApproved = computed(
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '', isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
]" ]"
> >
<!-- Compact thumbnail — small square preview on the left of the <!-- Compact thumbnail — small square preview on the left of the
row when the event carries an image. `self-center` keeps it row when the event carries an image. `self-center` keeps it
vertically centered against a taller content column so we vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. --> don't get a top-anchored thumb with dead space below. -->
<img <img
v-if="compact && event.image" v-if="compact && event.image"
:src="event.image" :src="event.image"
:alt="event.title" :alt="event.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md" class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy" loading="lazy"
/> />
<!-- Image with overlaid badges. Cards without an image (or in <!-- Image with overlaid badges. Cards without an image (or in
compact mode) skip the hero area entirely and surface their compact mode) skip the hero area entirely and surface their
badges inline at the top of the content block — the solid- badges inline at the top of the content block — the solid-
color placeholder + calendar glyph wasn't communicating color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. --> anything the title + details don't already. -->
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg"> <div
<img v-if="event.image && !compact"
:src="event.image" class="relative aspect-[16/9] overflow-hidden rounded-t-lg"
:alt="event.title"
class="w-full h-full object-cover"
loading="lazy"
/>
<!-- Category badge -->
<Badge
v-if="categoryLabel"
variant="secondary"
class="absolute top-2 left-2 text-xs"
> >
{{ categoryLabel }} <img
</Badge> :src="event.image"
:alt="event.title"
class="w-full h-full object-cover"
loading="lazy"
/>
<!-- Ownership badge — the creator can spot their own events at a <!-- Category badge -->
<Badge
v-if="categoryLabel"
variant="secondary"
class="absolute top-2 left-2 text-xs"
>
{{ categoryLabel }}
</Badge>
<!-- Ownership badge — the creator can spot their own events at a
glance on the feed. --> glance on the feed. -->
<Badge <Badge
v-if="event.isMine" v-if="event.isMine"
variant="outline" variant="outline"
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur" class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
<User class="w-3 h-3" /> <User class="w-3 h-3" />
Yours Yours
</Badge> </Badge>
<!-- Price badge --> <!-- Price badge -->
<Badge <Badge v-if="priceDisplay" class="absolute top-2 right-2 text-xs">
v-if="priceDisplay" {{ priceDisplay }}
class="absolute top-2 right-2 text-xs" </Badge>
>
{{ priceDisplay }}
</Badge>
<!-- Past badge — shown when the event has already ended. The <!-- Past badge — shown when the event has already ended. The
pending/rejected status badge that used to share this slot pending/rejected status badge that used to share this slot
is now an absolute overlay on Card root, above the wash-out, is now an absolute overlay on Card root, above the wash-out,
so we still suppress Past when isNonApproved (the status so we still suppress Past when isNonApproved (the status
badge is more actionable in that case). --> badge is more actionable in that case). -->
<Badge
v-if="isPast && !isNonApproved"
variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }}
</Badge>
</div>
<CardContent
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
>
<!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div v-if="!event.image || compact" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" />
Yours
</Badge>
<Badge <Badge
v-if="isPast && !isNonApproved" v-if="isPast && !isNonApproved"
variant="outline" variant="outline"
class="text-xs gap-1" class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
<History class="w-3 h-3" /> <History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }} {{ t("events.filters.past", "Past") }}
</Badge> </Badge>
</div> </div>
<!-- Title + Bookmark. Compact mode hides the bookmark (host's <CardContent
:class="
compact
? 'p-3 flex-1 flex flex-col gap-1.5'
: 'p-4 flex-1 flex flex-col gap-2'
"
>
<!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div
v-if="!event.image || compact"
class="flex flex-wrap items-center gap-1.5"
>
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge
v-if="event.isMine && !compact"
variant="outline"
class="text-xs gap-1"
>
<User class="w-3 h-3" />
Yours
</Badge>
<Badge
v-if="isPast && !isNonApproved"
variant="outline"
class="text-xs gap-1"
>
<History class="w-3 h-3" />
{{ t("events.filters.past", "Past") }}
</Badge>
</div>
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
own event — bookmarking it would be noise) and clamps the own event — bookmarking it would be noise) and clamps the
title to a single line. --> title to a single line. -->
<div class="flex items-start gap-1"> <div class="flex items-start gap-1">
<h3 <h3
:class="[ :class="[
'font-semibold text-foreground leading-tight flex-1', 'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2', compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]" ]"
>
{{ event.title }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
<!-- Summary (hidden in compact mode) -->
<p
v-if="event.summary && !compact"
class="text-sm text-muted-foreground line-clamp-2"
> >
{{ event.title }} {{ event.summary }}
</h3> </p>
<BookmarkButton
v-if="!compact"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
<!-- Summary (hidden in compact mode) --> <div
<p :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'"
v-if="event.summary && !compact" >
class="text-sm text-muted-foreground line-clamp-2" <!-- Organizer — small avatar + display name. Hidden in compact
>
{{ event.summary }}
</p>
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
<!-- Organizer — small avatar + display name. Hidden in compact
mode (host's own roster, no need to tell them whose event mode (host's own roster, no need to tell them whose event
it is) and on cards the user already owns. --> it is) and on cards the user already owns. -->
<OrganizerCard <OrganizerCard
v-if="!compact" v-if="!compact"
:pubkey="event.organizer.pubkey" :pubkey="event.organizer.pubkey"
compact compact
/> />
<!-- Date/Time --> <!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground"> <div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" /> <Calendar class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ dateDisplay }}</span> <span class="truncate">{{ dateDisplay }}</span>
</div> </div>
<!-- Location --> <!-- Location -->
<div <div
v-if="event.location" v-if="event.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground" class="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<MapPin class="w-3.5 h-3.5 shrink-0" /> <MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ event.location }}</span> <span class="truncate">{{ event.location }}</span>
</div> </div>
<!-- Tickets available. `available === undefined` means <!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. --> published); show the price-only line in that case. -->
<div <div
v-if="event.ticketInfo" v-if="event.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground" class="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<Ticket class="w-3.5 h-3.5 shrink-0" /> <Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="event.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
</span> </span>
<span v-else-if="event.ticketInfo.available > 0"> <span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }} {{
</span> t("events.detail.ticketsAvailable", {
<span v-else class="text-destructive font-medium"> count: event.ticketInfo.available,
{{ t('events.detail.soldOut') }} })
</span> }}
</div> </span>
<span v-else class="text-destructive font-medium">
{{ t("events.detail.soldOut") }}
</span>
</div>
<!-- Owned tickets — shown when the current user holds at <!-- Owned tickets — shown when the current user holds at
least one paid ticket for this event. Sits next to least one paid ticket for this event. Sits next to
the availability line so the buyer can see at a glance the availability line so the buyer can see at a glance
whether they've already bought in. --> whether they've already bought in. -->
<div <div
v-if="ownedCount > 0" v-if="ownedCount > 0"
class="flex items-center gap-1.5 text-sm text-primary" class="flex items-center gap-1.5 text-sm text-primary"
> >
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" /> <CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span> <span>
{{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }} {{
</span> t(
"events.detail.ticketsOwned",
{ count: ownedCount },
ownedCount,
)
}}
</span>
</div>
</div> </div>
</div> </CardContent>
</CardContent>
</div> </div>
<!-- Status badge — absolutely positioned on Card root so it sits <!-- Status badge — absolutely positioned on Card root so it sits
@ -288,7 +318,7 @@ const isNonApproved = computed(
variant="destructive" variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow" class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
> >
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review" }}
</Badge> </Badge>
</Card> </Card>
</template> </template>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed } from "vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, Trash2 } from "lucide-vue-next";
import { newWaveRow, validateWaveRows } from "../lib/ticketWaves";
import type { TicketWave } from "../types/ticket";
/**
* Additional ticket waves — the tiers after the first.
*
* The primary wave is edited through the dialog's own price / capacity /
* currency fields, because those event-level fields ARE the primary
* wave's (`sync_event_ticket_waves` derives them from it). This editor
* covers waves 2..n, the same split the LNbits admin uses.
*
* Plain `v-model` over a `TicketWave[]`, matching PromoCodesEditor:
* validation is the pure `validateWaveRows` so the parent can gate
* submit on exactly the rules the backend enforces.
*/
const props = defineProps<{
modelValue: TicketWave[];
/** Waves already stored on the event. A stored wave may legitimately
* sit at zero capacity — that is what sold out looks like — while a
* new one must state a real number. */
knownIds?: string[];
/** Currency of the primary wave; new rows inherit it. */
currency?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [waves: TicketWave[]] }>();
const errors = computed(() =>
validateWaveRows(props.modelValue, new Set(props.knownIds ?? [])),
);
function update(index: number, patch: Partial<TicketWave>) {
emit(
"update:modelValue",
props.modelValue.map((row, i) =>
i === index ? { ...row, ...patch } : row,
),
);
}
function add() {
emit("update:modelValue", [
...props.modelValue,
newWaveRow({ currency: props.currency ?? "sat" }),
]);
}
function remove(index: number) {
emit(
"update:modelValue",
props.modelValue.filter((_, i) => i !== index),
);
}
/** Empty input → 0 rather than NaN, so a cleared field reads as unset. */
function toNumber(value: string | number): number {
const n = Number(String(value).trim());
return Number.isFinite(n) ? n : 0;
}
/** A stored wave at zero has sold out; say so instead of flagging it. */
function isSoldOut(row: TicketWave): boolean {
return row.amount_tickets === 0 && (props.knownIds ?? []).includes(row.id);
}
</script>
<template>
<div class="space-y-3">
<p class="text-xs text-muted-foreground">
Extra pricing tiers, each with its own dates, price and capacity. Buyers
choose between whichever are open; the cheapest open one is what the
public listing advertises.
</p>
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
No additional waves — everything sells at the price above.
</p>
<div
v-for="(row, index) in modelValue"
:key="row.id"
class="rounded-md border border-border p-3 space-y-2"
>
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<div class="space-y-1">
<Label :for="`wave-title-${index}`" class="text-xs">Name</Label>
<Input
:id="`wave-title-${index}`"
:model-value="row.title"
placeholder="Early bird"
:disabled="disabled"
@update:model-value="update(index, { title: String($event) })"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="mt-5"
:disabled="disabled"
aria-label="Remove wave"
@click="remove(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label :for="`wave-open-${index}`" class="text-xs">Opens</Label>
<Input
:id="`wave-open-${index}`"
type="date"
:model-value="row.opening_date"
:disabled="disabled"
@update:model-value="
update(index, { opening_date: String($event) })
"
/>
</div>
<div class="space-y-1">
<Label :for="`wave-close-${index}`" class="text-xs">Closes</Label>
<Input
:id="`wave-close-${index}`"
type="date"
:model-value="row.closing_date"
:disabled="disabled"
@update:model-value="
update(index, { closing_date: String($event) })
"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label :for="`wave-price-${index}`" class="text-xs">
Price ({{ row.currency }})
</Label>
<Input
:id="`wave-price-${index}`"
type="number"
inputmode="decimal"
min="0"
step="any"
:model-value="row.price_per_ticket"
:disabled="disabled"
@update:model-value="
update(index, { price_per_ticket: toNumber($event) })
"
/>
</div>
<div class="space-y-1">
<Label :for="`wave-cap-${index}`" class="text-xs">Tickets</Label>
<Input
:id="`wave-cap-${index}`"
type="number"
inputmode="numeric"
min="1"
step="1"
:model-value="row.amount_tickets"
:disabled="disabled"
@update:model-value="
update(index, { amount_tickets: toNumber($event) })
"
/>
<p v-if="isSoldOut(row)" class="text-xs text-muted-foreground">
Sold out
</p>
</div>
</div>
<p v-if="errors[index]" class="text-xs text-destructive">
{{ errors[index] }}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
:disabled="disabled"
@click="add"
>
<Plus class="h-4 w-4 mr-1" />
Add wave
</Button>
</div>
</template>

View file

@ -1,7 +1,17 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue' import {
import { injectService, SERVICE_TOKENS } from '@/core/di-container' computed,
import type { TicketApiService } from '../services/TicketApiService' ref,
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes' 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. * Buyer-side promo state for the purchase dialog.
@ -17,111 +27,138 @@ import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCode
* every extension version. * every extension version.
*/ */
export type PromoState = export type PromoState =
| { status: 'idle' } | { status: "idle" }
| { status: 'checking'; code: string } | { status: "checking"; code: string }
| { | {
status: 'applied' status: "applied";
code: string code: string;
discountPercent: number discountPercent: number;
subtotal: number subtotal: number;
discount: number discount: number;
total: number total: number;
currency: string currency: string;
} }
| { status: 'invalid'; code: string } | { status: "invalid"; code: string }
| { status: 'unverified'; code: string } | { status: "unverified"; code: string };
const REVALIDATE_DEBOUNCE_MS = 300 const REVALIDATE_DEBOUNCE_MS = 300;
export function usePromoCode(eventId: MaybeRefOrGetter<string>, quantity: Ref<number>) { export function usePromoCode(
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService eventId: MaybeRefOrGetter<string>,
quantity: Ref<number>,
ticketWaveId?: MaybeRefOrGetter<string | undefined>,
) {
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
/** What the buyer typed (kept upper-case by the input handler). */ /** What the buyer typed (kept upper-case by the input handler). */
const input = ref('') const input = ref("");
const state = ref<PromoState>({ status: 'idle' }) const state = ref<PromoState>({ status: "idle" });
// Monotonic request counter so a slow response for a previous code or // Monotonic request counter so a slow response for a previous code or
// quantity can never overwrite a newer result. // quantity can never overwrite a newer result.
let requestSeq = 0 let requestSeq = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const isChecking = computed(() => state.value.status === 'checking') const isChecking = computed(() => state.value.status === "checking");
/** Code to put on the purchase request. `invalid` deliberately sends /** Code to put on the purchase request. `invalid` deliberately sends
* nothing so the buyer is not surprised by a 400 at pay time. */ * nothing so the buyer is not surprised by a 400 at pay time. */
const codeToSend = computed<string | undefined>(() => { const codeToSend = computed<string | undefined>(() => {
const s = state.value const s = state.value;
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined return s.status === "applied" || s.status === "unverified"
}) ? s.code
: undefined;
});
const appliedTotal = computed<number | null>(() => const appliedTotal = computed<number | null>(() =>
state.value.status === 'applied' ? state.value.total : null, state.value.status === "applied" ? state.value.total : null,
) );
async function validate(code: string): Promise<void> { async function validate(code: string): Promise<void> {
const seq = ++requestSeq const seq = ++requestSeq;
state.value = { status: 'checking', code } state.value = { status: "checking", code };
try { try {
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value) const totals = await ticketApi.validatePromoCode(
if (seq !== requestSeq) return toValue(eventId),
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0] code,
quantity.value,
toValue(ticketWaveId),
);
if (seq !== requestSeq) return;
const hit =
totals.discounts_applied.find((d) => d.code === code) ??
totals.discounts_applied[0];
if (!hit) { if (!hit) {
state.value = { status: 'invalid', code } state.value = { status: "invalid", code };
return return;
} }
state.value = { state.value = {
status: 'applied', status: "applied",
code: hit.code, code: hit.code,
discountPercent: hit.discount_percent ?? 0, discountPercent: hit.discount_percent ?? 0,
subtotal: totals.subtotal, subtotal: totals.subtotal,
discount: totals.discount, discount: totals.discount,
total: totals.total, total: totals.total,
currency: totals.currency, currency: totals.currency,
} };
} catch (err) { } catch (err) {
if (seq !== requestSeq) return if (seq !== requestSeq) return;
if (isUnsupportedEndpointError(err)) { if (isUnsupportedEndpointError(err)) {
state.value = { status: 'unverified', code } state.value = { status: "unverified", code };
return return;
} }
// Network / 5xx: don't block the purchase on an advisory call. // Network / 5xx: don't block the purchase on an advisory call.
console.warn('[usePromoCode] validate failed, sending code unverified:', err) console.warn(
state.value = { status: 'unverified', code } "[usePromoCode] validate failed, sending code unverified:",
err,
);
state.value = { status: "unverified", code };
} }
} }
async function apply(): Promise<void> { async function apply(): Promise<void> {
const code = normalizePromoCode(input.value) const code = normalizePromoCode(input.value);
if (!code) return if (!code) return;
input.value = code input.value = code;
await validate(code) await validate(code);
} }
function clear(): void { function clear(): void {
requestSeq++ requestSeq++;
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null debounceTimer = null;
input.value = '' input.value = "";
state.value = { status: 'idle' } state.value = { status: "idle" };
} }
/** Re-check the current code (quantity changed, or the purchase /** Re-check the current code (quantity changed, or the purchase
* endpoint rejected it — e.g. the last use went to someone else). */ * endpoint rejected it — e.g. the last use went to someone else). */
async function revalidate(): Promise<void> { async function revalidate(): Promise<void> {
const s = state.value const s = state.value;
if (s.status === 'idle' || s.status === 'checking') return if (s.status === "idle" || s.status === "checking") return;
await validate(s.code) await validate(s.code);
} }
watch(quantity, () => { watch(quantity, () => {
const s = state.value const s = state.value;
if (s.status === 'idle') return if (s.status === "idle") return;
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
debounceTimer = null debounceTimer = null;
void revalidate() void revalidate();
}, REVALIDATE_DEBOUNCE_MS) }, REVALIDATE_DEBOUNCE_MS);
}) });
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate } return {
input,
state,
isChecking,
codeToSend,
appliedTotal,
apply,
clear,
revalidate,
};
} }

View file

@ -1,49 +1,56 @@
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from "vue";
import { useAuth } from '@/composables/useAuthService' import { useAuth } from "@/composables/useAuthService";
import { toast } from 'vue-sonner' import { toast } from "vue-sonner";
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from "@/core/composables/useAsyncOperation";
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from "@/core/services/PaymentService";
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from "../services/TicketApiService";
import { useOwnedTickets } from './useOwnedTickets' import { useOwnedTickets } from "./useOwnedTickets";
import { frontendRoot } from '../lib/frontendRoot' import { frontendRoot } from "../lib/frontendRoot";
import { renderTicketQr } from '../lib/ticketQr' import { renderTicketQr } from "../lib/ticketQr";
/** Guest identity for a purchase without an LNbits account. */ /** Guest identity for a purchase without an LNbits account. */
export interface PurchaseContact { export interface PurchaseContact {
name: string name: string;
email: string email: string;
} }
export interface PurchaseTicketOptions { export interface PurchaseTicketOptions {
quantity?: number quantity?: number;
/** Guest buyer (no account). Required when not authenticated. */ /** Guest buyer (no account). Required when not authenticated. */
contact?: PurchaseContact contact?: PurchaseContact;
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */ /** Delivery email for a logged-in buyer (guests carry it in `contact`). */
email?: string email?: string;
/** Promo code to redeem (already normalised by the dialog). */ /** Promo code to redeem (already normalised by the dialog). */
promoCode?: string promoCode?: string;
/** Pricing tier the buyer chose. Required when several waves are open;
* the backend refuses to guess. */
ticketWaveId?: string;
} }
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth();
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const paymentService = injectService(
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService SERVICE_TOKENS.PAYMENT_SERVICE,
) as PaymentService;
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
// Refresh the shared owned-tickets singleton after a purchase so the // Refresh the shared owned-tickets singleton after a purchase so the
// feed/calendar "My tickets" filter and EventCard owned badges update // feed/calendar "My tickets" filter and EventCard owned badges update
// without a reload — purchase is exactly the "consumer that mutates // without a reload — purchase is exactly the "consumer that mutates
// the ticket set" useOwnedTickets's docs anticipate. // the ticket set" useOwnedTickets's docs anticipate.
const { refresh: refreshOwnedTickets } = useOwnedTickets() const { refresh: refreshOwnedTickets } = useOwnedTickets();
// Async operations // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation();
// State // State
const paymentHash = ref<string | null>(null) const paymentHash = ref<string | null>(null);
const paymentRequest = ref<string | null>(null) const paymentRequest = ref<string | null>(null);
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null);
const isPaymentPending = ref(false) const isPaymentPending = ref(false);
// Ticket QR code state. After payment lands, `purchasedTicketIds` // Ticket QR code state. After payment lands, `purchasedTicketIds`
// is populated with every row id created on the invoice (one for // is populated with every row id created on the invoice (one for
@ -51,62 +58,68 @@ export function useTicketPurchase() {
// parallel map id → QR data URL so the UI can render one QR per // parallel map id → QR data URL so the UI can render one QR per
// attendee. `purchasedTicketId` stays for back-compat with the // attendee. `purchasedTicketId` stays for back-compat with the
// single-id success path. // single-id success path.
const ticketQRCode = ref<string | null>(null) const ticketQRCode = ref<string | null>(null);
const ticketQRCodes = ref<Record<string, string>>({}) const ticketQRCodes = ref<Record<string, string>>({});
const purchasedTicketId = ref<string | null>(null) const purchasedTicketId = ref<string | null>(null);
const purchasedTicketIds = ref<string[]>([]) const purchasedTicketIds = ref<string[]>([]);
const showTicketQR = ref(false) const showTicketQR = ref(false);
// Computed properties // Computed properties
const canPurchase = computed(() => isAuthenticated.value && currentUser.value) const canPurchase = computed(
() => isAuthenticated.value && currentUser.value,
);
const userDisplay = computed(() => { const userDisplay = computed(() => {
if (!currentUser.value) return null if (!currentUser.value) return null;
return { return {
name: currentUser.value.username || currentUser.value.id, name: currentUser.value.username || currentUser.value.id,
shortId: currentUser.value.id.slice(0, 8) shortId: currentUser.value.id.slice(0, 8),
} };
}) });
// Delegate to PaymentService // Delegate to PaymentService
const userWallets = computed(() => paymentService.userWallets) const userWallets = computed(() => paymentService.userWallets);
const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance) const hasWalletWithBalance = computed(
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value) () => paymentService.hasWalletWithBalance,
);
const isPayingWithWallet = computed(
() => paymentService.isProcessingPayment.value,
);
async function generateQRCode(bolt11: string) { async function generateQRCode(bolt11: string) {
try { try {
qrCode.value = await paymentService.generateQRCode(bolt11) qrCode.value = await paymentService.generateQRCode(bolt11);
} catch (err) { } catch (err) {
console.error('Error generating QR code:', err) console.error("Error generating QR code:", err);
} }
} }
async function generateTicketQRCode(ticketId: string) { async function generateTicketQRCode(ticketId: string) {
try { try {
const dataUrl = await renderTicketQr(ticketId, { width: 160 }) const dataUrl = await renderTicketQr(ticketId, { width: 160 });
ticketQRCode.value = dataUrl ticketQRCode.value = dataUrl;
return dataUrl return dataUrl;
} catch (error) { } catch (error) {
console.error('Error generating ticket QR code:', error) console.error("Error generating ticket QR code:", error);
return null return null;
} }
} }
async function payWithWallet(pr: string) { async function payWithWallet(pr: string) {
try { try {
await paymentService.payWithWallet(pr, undefined, { await paymentService.payWithWallet(pr, undefined, {
showToast: false showToast: false,
}) });
return true return true;
} catch (error) { } catch (error) {
console.error('Wallet payment failed:', error) console.error("Wallet payment failed:", error);
throw error throw error;
} }
} }
/** The event id this composable is currently driving — kept so /** The event id this composable is currently driving — kept so
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't * `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* have to take it as an argument from the UI. */ * have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null) const currentEventId = ref<string | null>(null);
/** /**
* Advance to the ticket-QR success state for the given row ids. Shared * Advance to the ticket-QR success state for the given row ids. Shared
@ -118,26 +131,26 @@ export function useTicketPurchase() {
// Ticket row(s) now exist — refresh the shared owned-tickets state so // Ticket row(s) now exist — refresh the shared owned-tickets state so
// the feed/calendar My-tickets filter and owned badges reflect the // the feed/calendar My-tickets filter and owned badges reflect the
// purchase immediately (no reload). Runs in parallel with QR gen. // purchase immediately (no reload). Runs in parallel with QR gen.
void refreshOwnedTickets() void refreshOwnedTickets();
if (ids.length > 0) { if (ids.length > 0) {
purchasedTicketIds.value = ids purchasedTicketIds.value = ids;
purchasedTicketId.value = ids[0] purchasedTicketId.value = ids[0];
const qrMap: Record<string, string> = {} const qrMap: Record<string, string> = {};
for (const id of ids) { for (const id of ids) {
const dataUrl = await generateTicketQRCode(id) const dataUrl = await generateTicketQRCode(id);
if (dataUrl) qrMap[id] = dataUrl if (dataUrl) qrMap[id] = dataUrl;
} }
ticketQRCodes.value = qrMap ticketQRCodes.value = qrMap;
ticketQRCode.value = qrMap[ids[0]] ?? null ticketQRCode.value = qrMap[ids[0]] ?? null;
showTicketQR.value = true showTicketQR.value = true;
} }
toast.success( toast.success(
ids.length > 1 ids.length > 1
? `${ids.length} tickets purchased!` ? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!', : "Ticket purchased successfully!",
) );
} }
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
@ -147,78 +160,82 @@ export function useTicketPurchase() {
// Guests are identified by name + email; logged-in buyers by user id. // Guests are identified by name + email; logged-in buyers by user id.
// Either is enough for a paid ticket — free tickets stay login-gated // Either is enough for a paid ticket — free tickets stay login-gated
// in the dialog (events#29: no anonymous free issuance). // in the dialog (events#29: no anonymous free issuance).
const isGuest = !canPurchase.value || !currentUser.value const isGuest = !canPurchase.value || !currentUser.value;
if (isGuest && !options.contact) { if (isGuest && !options.contact) {
throw new Error('User must be authenticated to purchase tickets') throw new Error("User must be authenticated to purchase tickets");
} }
return await purchaseOperation.execute(async () => { return await purchaseOperation.execute(
// Clear previous state async () => {
paymentHash.value = null // Clear previous state
paymentRequest.value = null paymentHash.value = null;
qrCode.value = null paymentRequest.value = null;
ticketQRCode.value = null qrCode.value = null;
ticketQRCodes.value = {} ticketQRCode.value = null;
purchasedTicketId.value = null ticketQRCodes.value = {};
purchasedTicketIds.value = [] purchasedTicketId.value = null;
showTicketQR.value = false purchasedTicketIds.value = [];
currentEventId.value = eventId showTicketQR.value = false;
currentEventId.value = eventId;
// Get the invoice via TicketApiService // Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any;
const accessToken = lnbitsAPI?.getAccessToken?.() || '' const accessToken = lnbitsAPI?.getAccessToken?.() || "";
const invoice = await ticketApi.requestTicket(eventId, { const invoice = await ticketApi.requestTicket(eventId, {
userId: isGuest ? undefined : currentUser.value!.id, userId: isGuest ? undefined : currentUser.value!.id,
name: isGuest ? options.contact?.name : undefined, name: isGuest ? options.contact?.name : undefined,
email: isGuest ? options.contact?.email : options.email, email: isGuest ? options.contact?.email : options.email,
accessToken: isGuest ? undefined : accessToken, accessToken: isGuest ? undefined : accessToken,
frontendUrl: frontendRoot(), frontendUrl: frontendRoot(),
quantity: options.quantity, quantity: options.quantity,
promoCode: options.promoCode, promoCode: options.promoCode,
}) ticketWaveId: options.ticketWaveId,
});
// Free tickets (price 0 / 100%-off promo): the backend already // Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip // issued + marked them paid, so there's no invoice to settle. Skip
// the QR / payment-poll and jump straight to the ticket-QR success // the QR / payment-poll and jump straight to the ticket-QR success
// state with the ids returned inline. // state with the ids returned inline.
if (invoice.paid) { if (invoice.paid) {
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash;
await finalizePurchasedTickets(invoice.ticketIds ?? []) await finalizePurchasedTickets(invoice.ticketIds ?? []);
return invoice return invoice;
} }
// Otherwise the backend returns either a Lightning invoice or a fiat // Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how // checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate // to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it // redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat // has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent // response here so callers get a clear error instead of a silent
// broken QR. // broken QR.
if (invoice.isFiat || !invoice.paymentRequest) { if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error( throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' + "This event uses fiat checkout. Use the purchase dialog " +
'to follow the provider link.', "to follow the provider link.",
) );
} }
const bolt11: string = invoice.paymentRequest const bolt11: string = invoice.paymentRequest;
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash;
paymentRequest.value = bolt11 paymentRequest.value = bolt11;
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(bolt11) await generateQRCode(bolt11);
// Restaurant-style: don't auto-pay. Surface the QR + amount and // Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in // let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just // external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI // starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state. // advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash) await startPaymentStatusCheck(eventId, invoice.paymentHash);
return invoice return invoice;
}, { },
errorMessage: 'Failed to purchase ticket' {
}) errorMessage: "Failed to purchase ticket",
},
);
} }
/** /**
@ -227,76 +244,77 @@ export function useTicketPurchase() {
* my LNbits wallet" button on the invoice screen. * my LNbits wallet" button on the invoice screen.
*/ */
async function payCurrentInvoiceWithWallet(): Promise<void> { async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return if (!paymentRequest.value) return;
await payWithWallet(paymentRequest.value) await payWithWallet(paymentRequest.value);
// Polling is already running from purchaseTicketForEvent — when // Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to // the payment lands, it advances to showTicketQR. No need to
// restart it here. // restart it here.
} }
async function startPaymentStatusCheck(eventId: string, hash: string) { async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true isPaymentPending.value = true;
let checkInterval: number | null = null let checkInterval: number | null = null;
const checkPayment = async () => { const checkPayment = async () => {
try { try {
const result = await ticketApi.checkPaymentStatus(eventId, hash) const result = await ticketApi.checkPaymentStatus(eventId, hash);
if (result.paid) { if (result.paid) {
isPaymentPending.value = false isPaymentPending.value = false;
if (checkInterval) { if (checkInterval) {
clearInterval(checkInterval) clearInterval(checkInterval);
} }
// Multi-ticket purchases come back with `ticketIds` (N rows // Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include // sharing one invoice). Single-ticket purchases include
// `ticketId` only. // `ticketId` only.
const ids = result.ticketIds && result.ticketIds.length > 0 const ids =
? result.ticketIds result.ticketIds && result.ticketIds.length > 0
: result.ticketId ? result.ticketIds
? [result.ticketId] : result.ticketId
: [] ? [result.ticketId]
await finalizePurchasedTickets(ids) : [];
await finalizePurchasedTickets(ids);
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error("Error checking payment status:", err);
} }
} };
await checkPayment() await checkPayment();
checkInterval = setInterval(checkPayment, 2000) as unknown as number checkInterval = setInterval(checkPayment, 2000) as unknown as number;
} }
function stopPaymentStatusCheck() { function stopPaymentStatusCheck() {
isPaymentPending.value = false isPaymentPending.value = false;
} }
function resetPaymentState() { function resetPaymentState() {
purchaseOperation.clear() purchaseOperation.clear();
paymentHash.value = null paymentHash.value = null;
paymentRequest.value = null paymentRequest.value = null;
qrCode.value = null qrCode.value = null;
isPaymentPending.value = false isPaymentPending.value = false;
ticketQRCode.value = null ticketQRCode.value = null;
ticketQRCodes.value = {} ticketQRCodes.value = {};
purchasedTicketId.value = null purchasedTicketId.value = null;
purchasedTicketIds.value = [] purchasedTicketIds.value = [];
showTicketQR.value = false showTicketQR.value = false;
} }
function handleOpenLightningWallet() { function handleOpenLightningWallet() {
if (paymentRequest.value) { if (paymentRequest.value) {
paymentService.openExternalWallet(paymentRequest.value) paymentService.openExternalWallet(paymentRequest.value);
} }
} }
function cleanup() { function cleanup() {
stopPaymentStatusCheck() stopPaymentStatusCheck();
} }
onUnmounted(() => { onUnmounted(() => {
cleanup() cleanup();
}) });
return { return {
// State // State
@ -329,6 +347,6 @@ export function useTicketPurchase() {
// Exposed so the dialog's fiat branch can land a 100 %-off purchase // Exposed so the dialog's fiat branch can land a 100 %-off purchase
// (backend returns `paid: true`, no checkout URL) on the same // (backend returns `paid: true`, no checkout URL) on the same
// success screen as the Lightning / free paths. // success screen as the Lightning / free paths.
finalizePurchasedTickets finalizePurchasedTickets,
} };
} }

View file

@ -0,0 +1,329 @@
import { describe, it, expect } from "vitest";
import {
activeTicketWaves,
advertisedTicketWave,
applyFormToPrimaryWave,
ensureTicketWaves,
isWaveActive,
newWaveRow,
resolveTicketWave,
syncEventFromWaves,
validateWaveRows,
waveDay,
} from "./ticketWaves";
import type { TicketWave } from "../types/ticket";
const TODAY = "2026-06-15";
function wave(overrides: Partial<TicketWave> & { id: string }): TicketWave {
return {
title: overrides.id,
opening_date: "2026-06-01",
closing_date: "2026-06-30",
currency: "sat",
amount_tickets: 10,
price_per_ticket: 100,
...overrides,
};
}
const EARLY = wave({
id: "early",
price_per_ticket: 10,
amount_tickets: 5,
closing_date: "2026-06-10",
});
const REGULAR = wave({
id: "regular",
price_per_ticket: 25,
amount_tickets: 40,
});
const VIP = wave({
id: "vip",
price_per_ticket: 50,
opening_date: "2026-07-01",
closing_date: "2026-07-30",
});
const eventWith = (waves: TicketWave[]) => ({ extra: { ticket_waves: waves } });
describe("waveDay", () => {
it("takes the date component, tolerating the datetimes our closing dates carry", () => {
expect(waveDay("2026-06-15")).toBe("2026-06-15");
expect(waveDay("2026-06-15T18:30:00")).toBe("2026-06-15");
expect(waveDay(null)).toBe("");
});
});
describe("ensureTicketWaves", () => {
it("returns stored waves untouched", () => {
expect(
ensureTicketWaves(eventWith([EARLY, REGULAR])).map((w) => w.id),
).toEqual(["early", "regular"]);
});
it("synthesizes a primary wave for events from before waves existed", () => {
const [primary] = ensureTicketWaves({
closing_date: "2026-06-30T20:00:00",
currency: "EUR",
amount_tickets: 40,
price_per_ticket: 12.5,
});
expect(primary.id).toBe("primary");
expect(primary.amount_tickets).toBe(40);
expect(primary.price_per_ticket).toBe(12.5);
// Verified against the backend: it keeps the time component here, and
// `syncEventFromWaves` maxes these as strings, so truncating would make
// the client derive a different event closing_date than the server.
expect(primary.closing_date).toBe("2026-06-30T20:00:00");
expect(primary.opening_date).toBe("2026-06-30T20:00:00");
});
it("opens on the creation day when the record carries one", () => {
const [primary] = ensureTicketWaves({
time: "2026-05-01T09:00:00",
closing_date: "2026-06-30T20:00:00",
amount_tickets: 5,
});
expect(primary.opening_date).toBe("2026-05-01");
});
});
describe("isWaveActive", () => {
it("is inclusive of both boundary days", () => {
expect(
isWaveActive(
wave({ id: "a", opening_date: TODAY, closing_date: TODAY }),
TODAY,
),
).toBe(true);
});
it("is false before it opens, after it closes, and when sold out", () => {
expect(isWaveActive(VIP, TODAY)).toBe(false);
expect(isWaveActive(EARLY, TODAY)).toBe(false);
expect(isWaveActive(wave({ id: "gone", amount_tickets: 0 }), TODAY)).toBe(
false,
);
});
});
describe("activeTicketWaves", () => {
it("returns every open wave, closed and unopened ones excluded", () => {
const cheaper = wave({ id: "cheap", price_per_ticket: 15 });
expect(
activeTicketWaves(eventWith([EARLY, REGULAR, VIP, cheaper]), TODAY).map(
(w) => w.id,
),
).toEqual(["regular", "cheap"]);
});
});
describe("advertisedTicketWave", () => {
it("ignores a closed wave even when it is the primary and the cheapest", () => {
const advertised = advertisedTicketWave(
eventWith([EARLY, REGULAR, VIP]),
TODAY,
);
expect(advertised?.id).toBe("regular");
});
it("shows the cheapest open wave when several overlap", () => {
const cheaper = wave({
id: "cheap",
price_per_ticket: 15,
amount_tickets: 3,
});
expect(advertisedTicketWave(eventWith([REGULAR, cheaper]), TODAY)?.id).toBe(
"cheap",
);
});
it("is null when nothing is on sale", () => {
expect(advertisedTicketWave(eventWith([EARLY]), TODAY)).toBeNull();
expect(advertisedTicketWave(eventWith([VIP]), TODAY)).toBeNull();
});
});
describe("resolveTicketWave", () => {
it("implies a single open wave", () => {
const result = resolveTicketWave(
eventWith([EARLY, REGULAR]),
undefined,
TODAY,
);
expect(result).toEqual({ ok: true, wave: REGULAR });
});
it("refuses to guess between several open waves", () => {
const cheaper = wave({ id: "cheap", price_per_ticket: 15 });
expect(
resolveTicketWave(eventWith([REGULAR, cheaper]), undefined, TODAY),
).toEqual({ ok: false, reason: "must-choose" });
});
it("accepts an explicit open wave and rejects a closed one", () => {
expect(
resolveTicketWave(eventWith([EARLY, REGULAR]), "regular", TODAY),
).toEqual({ ok: true, wave: REGULAR });
expect(
resolveTicketWave(eventWith([EARLY, REGULAR]), "early", TODAY),
).toEqual({ ok: false, reason: "not-open" });
});
it("reports when nothing is open at all", () => {
expect(resolveTicketWave(eventWith([EARLY]), undefined, TODAY)).toEqual({
ok: false,
reason: "none-open",
});
});
});
describe("syncEventFromWaves", () => {
it("derives the event-level fields the way the backend does", () => {
const synced = syncEventFromWaves(eventWith([EARLY, REGULAR, VIP]));
expect(synced.amount_tickets).toBe(55); // sum of every wave
expect(synced.price_per_ticket).toBe(10); // the FIRST wave, not the cheapest open one
expect(synced.closing_date).toBe("2026-07-30"); // the latest wave's
});
});
describe("applyFormToPrimaryWave", () => {
it("writes the form values into the primary wave", () => {
const [primary, ...rest] = applyFormToPrimaryWave([EARLY, REGULAR], {
amount_tickets: 99,
price_per_ticket: 42,
currency: "EUR",
});
expect(primary.amount_tickets).toBe(99);
expect(primary.price_per_ticket).toBe(42);
expect(primary.currency).toBe("EUR");
expect(rest.map((w) => w.id)).toEqual(["regular"]);
});
it("is what stops an organizer edit being silently discarded", () => {
// Without the write-through the wave keeps its old number and the
// backend recomputes amount_tickets straight back to it.
const untouched = syncEventFromWaves(eventWith([EARLY, REGULAR]));
expect(untouched.amount_tickets).toBe(45);
const applied = applyFormToPrimaryWave([EARLY, REGULAR], {
amount_tickets: 99,
});
expect(syncEventFromWaves(eventWith(applied)).amount_tickets).toBe(139);
});
it("does not mutate the caller’s waves", () => {
const waves = [EARLY, REGULAR];
applyFormToPrimaryWave(waves, { amount_tickets: 1 });
expect(waves[0].amount_tickets).toBe(5);
});
it("creates a primary wave when the event has none", () => {
const [primary] = applyFormToPrimaryWave([], {
amount_tickets: 7,
price_per_ticket: 3,
});
expect(primary.id).toBe("primary");
expect(primary.amount_tickets).toBe(7);
});
});
describe("validateWaveRows", () => {
const valid = wave({ id: "w1", title: "Late", amount_tickets: 5 });
it("accepts a well-formed wave", () => {
expect(validateWaveRows([valid])).toEqual({});
});
it("rejects a new wave with no capacity — there is no unlimited", () => {
expect(validateWaveRows([{ ...valid, amount_tickets: 0 }])[0]).toMatch(
/at least 1/,
);
});
it("lets a wave that sold out stay at zero, so the event stays editable", () => {
const soldOut = { ...valid, amount_tickets: 0 };
expect(validateWaveRows([soldOut], new Set(["w1"]))).toEqual({});
});
it("catches missing names, inverted windows and negative prices", () => {
expect(validateWaveRows([{ ...valid, title: " " }])[0]).toMatch(/name/);
expect(
validateWaveRows([
{ ...valid, opening_date: "2026-07-01", closing_date: "2026-06-01" },
])[0],
).toMatch(/Opens after/);
expect(validateWaveRows([{ ...valid, price_per_ticket: -1 }])[0]).toMatch(
/negative/,
);
});
it("reports the offending row by index", () => {
const errors = validateWaveRows([valid, { ...valid, id: "w2", title: "" }]);
expect(errors[0]).toBeUndefined();
expect(errors[1]).toMatch(/name/);
});
});
describe("newWaveRow", () => {
it("starts at the smallest capacity the backend accepts", () => {
expect(newWaveRow().amount_tickets).toBe(1);
});
it("gives each row a distinct id so the list keys stay stable", () => {
expect(newWaveRow().id).not.toBe(newWaveRow().id);
});
});
describe("the primary-wave write-through a create/edit form performs", () => {
// Composition the CreateEventDialog runs on submit. Pinned here because
// getting it wrong is silent: the backend accepts the payload and then
// either discards the organizer's numbers or throws on every read.
function submitted(
stored: {
time?: string;
closing_date?: string | null;
event_start_date?: string;
extra?: { ticket_waves?: TicketWave[] } | null;
},
form: {
amount_tickets: number;
price_per_ticket: number;
currency: string;
},
) {
return applyFormToPrimaryWave(ensureTicketWaves(stored), form)[0];
}
it("never emits empty wave dates for a brand-new event", () => {
// The seed matters: with no dates at all `ensureTicketWaves` produces
// '' and the backend's date parse raises on every subsequent read.
const primary = submitted(
{ time: "2026-06-15T10:00:00Z", event_start_date: "2026-09-01" },
{ amount_tickets: 50, price_per_ticket: 10, currency: "sat" },
);
expect(primary.opening_date).toBe("2026-06-15");
expect(primary.closing_date).toBe("2026-09-01");
expect(primary.opening_date).not.toBe("");
expect(primary.closing_date).not.toBe("");
});
it("carries the form numbers into the stored primary wave on edit", () => {
const primary = submitted(
{ extra: { ticket_waves: [EARLY, REGULAR] } },
{ amount_tickets: 99, price_per_ticket: 42, currency: "EUR" },
);
expect(primary.id).toBe("early");
expect(primary.amount_tickets).toBe(99);
expect(primary.price_per_ticket).toBe(42);
});
it("leaves the later waves untouched", () => {
const waves = applyFormToPrimaryWave(
ensureTicketWaves({ extra: { ticket_waves: [EARLY, REGULAR] } }),
{ amount_tickets: 99 },
);
expect(waves[1]).toEqual(REGULAR);
});
});

View file

@ -0,0 +1,294 @@
import type { TicketWave } from "../types/ticket";
/**
* Ticket-wave arithmetic, mirroring the events extension so the webapp
* cannot disagree with the backend about which wave is open, what it
* costs, or what the event-level numbers should be.
*
* Backend counterparts live in the extension's `models.py`:
* `ensure_ticket_waves`, `get_active_ticket_waves`,
* `advertised_ticket_wave`, `sync_event_ticket_waves`.
*
* The one rule behind all of it: since v1.6.8 price, currency and stock
* belong to a WAVE. The event-level fields are derived — `amount_tickets`
* is the sum across waves, `price_per_ticket` and `currency` are the
* FIRST wave's — so reading them to price or count anything is a bug.
*/
/** Shape this module needs from an event; both the REST and form models fit. */
export interface WaveBearingEvent {
/** Creation timestamp; present on organizer records, absent on public ones. */
time?: string | number | null;
closing_date?: string | null;
event_start_date?: string;
event_end_date?: string | null;
currency?: string;
allow_fiat?: boolean;
fiat_currency?: string;
amount_tickets?: number;
price_per_ticket?: number;
extra?: { ticket_waves?: TicketWave[] } | null;
}
/**
* Date component of an ISO value.
*
* Mirrors the backend's `_parse_date`, which takes the first ten
* characters rather than parsing: a fork event's `closing_date` may carry
* a time (it defaults from `event_end_date`), and a bare date-only parse
* throws on those.
*/
export function waveDay(value: string | null | undefined): string {
return (value ?? "").slice(0, 10);
}
/** Today in the same YYYY-MM-DD form waves are compared in. */
export function todayKey(now: Date = new Date()): string {
return now.toISOString().slice(0, 10);
}
/**
* The event's waves, synthesizing a primary one when it has none.
*
* Events created before waves existed carry no list, and the backend
* gives them an implied single wave seeded from the event-level fields
* rather than treating them as unsellable.
*/
export function ensureTicketWaves(event: WaveBearingEvent): TicketWave[] {
const stored = event.extra?.ticket_waves ?? [];
if (stored.length > 0) return stored;
// Mirrors the backend byte for byte, INCLUDING leaving any time
// component on `closing_date`. Stripping it here looks tidier and is
// wrong: `syncEventFromWaves` takes a string max over wave closing
// dates, so a truncated value makes the client derive a different event
// closing date than the server does. Truncation belongs in `waveDay`, at
// comparison time, which is where the backend's `_parse_date` does it.
const closing =
event.closing_date || event.event_end_date || event.event_start_date || "";
// Opening falls back to the event's creation day when there is one
// (organizer records), else to the closing value — `getattr(event,
// "time", None)` in the backend, which public events don't carry.
const opening = event.time != null ? waveDay(String(event.time)) : closing;
return [
{
id: "primary",
title: "Primary wave",
opening_date: opening,
closing_date: closing,
currency: event.currency ?? "sat",
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? "GBP",
amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0,
},
];
}
/** A wave is open when today falls inside its window and it has stock left. */
export function isWaveActive(
wave: TicketWave,
today: string = todayKey(),
): boolean {
return (
waveDay(wave.opening_date) <= today &&
today <= waveDay(wave.closing_date) &&
wave.amount_tickets > 0
);
}
/** Every wave a buyer could currently buy from. */
export function activeTicketWaves(
event: WaveBearingEvent,
today: string = todayKey(),
): TicketWave[] {
return ensureTicketWaves(event).filter((wave) => isWaveActive(wave, today));
}
/**
* The wave a listing should describe, or null when nothing is on sale.
*
* Several waves can be open at once and a card cannot ask the reader which
* tier they want, so it shows the CHEAPEST — the price a buyer is actually
* able to obtain. Same rule the backend publishes to Nostr.
*/
export function advertisedTicketWave(
event: WaveBearingEvent,
today: string = todayKey(),
): TicketWave | null {
const active = activeTicketWaves(event, today);
if (active.length === 0) return null;
return active.reduce((cheapest, wave) =>
wave.price_per_ticket < cheapest.price_per_ticket ? wave : cheapest,
);
}
/**
* The wave a purchase should use, given the buyer's choice.
*
* Mirrors `_resolve_ticket_wave`: an explicit id must name an OPEN wave, a
* single open wave is implied, and an ambiguous choice is an error rather
* than a silent pick. Returning the reason lets the dialog show a picker
* instead of firing a request the backend will refuse.
*/
export type WaveResolution =
| { ok: true; wave: TicketWave }
| { ok: false; reason: "none-open" | "must-choose" | "not-open" };
export function resolveTicketWave(
event: WaveBearingEvent,
ticketWaveId?: string | null,
today: string = todayKey(),
): WaveResolution {
const active = activeTicketWaves(event, today);
if (active.length === 0) return { ok: false, reason: "none-open" };
if (ticketWaveId) {
const wave = active.find((w) => w.id === ticketWaveId);
return wave ? { ok: true, wave } : { ok: false, reason: "not-open" };
}
if (active.length === 1) return { ok: true, wave: active[0] };
return { ok: false, reason: "must-choose" };
}
/**
* Event-level fields recomputed from the waves.
*
* Mirrors `sync_event_ticket_waves`, which runs on every backend read and
* write. The webapp needs it to show an organizer what their edit will
* actually produce, rather than echoing numbers the server is about to
* overwrite.
*/
export type SyncedEvent<T> = T & {
closing_date: string;
currency: string;
allow_fiat: boolean;
fiat_currency: string;
amount_tickets: number;
price_per_ticket: number;
extra: { ticket_waves: TicketWave[] };
};
export function syncEventFromWaves<T extends WaveBearingEvent>(
event: T,
): SyncedEvent<T> {
const waves = ensureTicketWaves(event);
const primary = waves[0];
return {
...event,
closing_date: waves
.map((wave) => wave.closing_date)
.reduce((latest, date) => (date > latest ? date : latest)),
currency: primary.currency,
allow_fiat: primary.allow_fiat ?? false,
fiat_currency: primary.fiat_currency ?? "GBP",
amount_tickets: waves.reduce((sum, wave) => sum + wave.amount_tickets, 0),
price_per_ticket: primary.price_per_ticket,
extra: { ...(event.extra ?? {}), ticket_waves: waves },
};
}
/**
* Push the top-level form values into the primary wave before saving.
*
* Without this an organizer's capacity or price edit is silently
* discarded: the backend derives those fields FROM the waves on every
* write, so submitting a changed `amount_tickets` alongside an unchanged
* wave list leaves the wave's number winning. The LNbits admin dialog does
* the same thing (`syncPrimaryWaveFromForm`).
*/
export function applyFormToPrimaryWave(
waves: TicketWave[],
values: {
closing_date?: string | null;
currency?: string;
allow_fiat?: boolean;
fiat_currency?: string;
amount_tickets?: number;
price_per_ticket?: number;
},
): TicketWave[] {
const next =
waves.length > 0 ? [...waves] : ensureTicketWaves({ extra: null });
const primary = { ...next[0] };
if (values.closing_date != null)
primary.closing_date = waveDay(values.closing_date);
if (values.currency != null) primary.currency = values.currency;
if (values.allow_fiat != null) primary.allow_fiat = values.allow_fiat;
if (values.fiat_currency != null)
primary.fiat_currency = values.fiat_currency;
if (values.amount_tickets != null)
primary.amount_tickets = values.amount_tickets;
if (values.price_per_ticket != null)
primary.price_per_ticket = values.price_per_ticket;
next[0] = primary;
return next;
}
/** Per-row problems, keyed by wave index. Empty means the list is valid. */
export type WaveRowErrors = Record<number, string>;
/**
* Validate organizer-entered waves, mirroring `_validate_wave_capacity`
* plus the shape rules the backend model enforces.
*
* `knownIds` are the waves already stored on the event. Selling out is
* the legitimate route to zero, so an existing wave may sit at 0 and
* still be saved; only a wave new to the event must state real capacity.
* Rejecting stored zeroes would make a sold-out event uneditable.
*/
export function validateWaveRows(
rows: TicketWave[],
knownIds: ReadonlySet<string> = new Set(),
): WaveRowErrors {
const errors: WaveRowErrors = {};
const seen = new Set<string>();
rows.forEach((row, index) => {
if (!row.title?.trim()) {
errors[index] = "Give this wave a name";
} else if (!row.opening_date || !row.closing_date) {
errors[index] = "Set both an opening and a closing date";
} else if (waveDay(row.opening_date) > waveDay(row.closing_date)) {
errors[index] = "Opens after it closes";
} else if (
!Number.isFinite(row.price_per_ticket) ||
row.price_per_ticket < 0
) {
errors[index] = "Price cannot be negative";
} else if (
!knownIds.has(row.id) &&
(!Number.isFinite(row.amount_tickets) || row.amount_tickets < 1)
) {
// Matches the backend: a zero-capacity wave can never be active, so
// creating one produces a tier that refuses every purchase.
errors[index] = "Needs a capacity of at least 1";
} else if (row.amount_tickets < 0) {
errors[index] = "Capacity cannot be negative";
} else if (seen.has(row.id)) {
errors[index] = "Duplicate wave";
}
seen.add(row.id);
});
return errors;
}
/** A blank wave row for the editor, opening today and priced like the event. */
export function newWaveRow(defaults: Partial<TicketWave> = {}): TicketWave {
const today = todayKey();
return {
id: `w${Math.random().toString(36).slice(2, 10)}`,
title: "",
opening_date: today,
closing_date: today,
currency: "sat",
// 1, not 0: the smallest capacity the backend accepts. There is no
// unlimited (aiolabs/events#34).
amount_tickets: 1,
price_per_ticket: 0,
...defaults,
};
}

View file

@ -1,146 +1,300 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TicketApiService } from './TicketApiService' import { TicketApiService } from "./TicketApiService";
const BASE = 'https://lnbits.test' const BASE = "https://lnbits.test";
function okJson(body: unknown) { function okJson(body: unknown) {
return { ok: true, json: async () => body } as Response return { ok: true, json: async () => body } as Response;
} }
describe('TicketApiService.requestTicket', () => { describe("TicketApiService.requestTicket", () => {
const fetchMock = vi.fn() const fetchMock = vi.fn();
let api: TicketApiService let api: TicketApiService;
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset() fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
}) });
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals());
function lastCall() { function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] 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> } 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 () => { 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'] })) fetchMock.mockResolvedValue(
const res = await api.requestTicket('evt', { okJson({
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events', payment_hash: "h",
paymentMethod: 'fiat', quantity: 2, fiat_payment_request: "https://checkout",
}) is_fiat: true,
const { url, body, headers } = lastCall() ticket_ids: ["a", "b"],
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`) }),
);
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({ expect(body).toEqual({
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events', name: "Guest",
payment_method: 'fiat', quantity: 2, email: "g@example.com",
}) frontend_url: "https://demo.aiolabs.dev/events",
expect(body.user_id).toBeUndefined() payment_method: "fiat",
expect(headers.Authorization).toBeUndefined() quantity: 2,
expect(headers['X-API-KEY']).toBe('inst-key') });
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] }) 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 () => { 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 })) fetchMock.mockResolvedValue(
await api.requestTicket('evt', { okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173', );
}) await api.requestTicket("evt", {
const { body, headers } = lastCall() userId: "u1",
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' }) email: "me@example.com",
expect(headers.Authorization).toBe('Bearer tok') 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 () => { 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) fetchMock.mockResolvedValue({
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' })) ok: false,
.rejects.toThrow('Payment method not enabled for this event.') 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', () => { describe("TicketApiService.getTicket / resendTicketEmail", () => {
const fetchMock = vi.fn() const fetchMock = vi.fn();
let api: TicketApiService let api: TicketApiService;
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset() fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' }) api = new TicketApiService({ baseUrl: BASE, apiKey: "k" });
}) });
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals());
it('maps the anonymous PublicTicket shape', async () => { it("maps the anonymous PublicTicket shape", async () => {
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' })) fetchMock.mockResolvedValue(
const t = await api.getTicket('tid') okJson({
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`) event: "evt",
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' }) 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 () => { 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: {} } const ticket = {
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } })) id: "tid",
const r = await api.resendTicketEmail('tid', 'admin') wallet: "w",
expect(r.ticket.id).toBe('tid') event: "evt",
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' }) name: "G",
expect(r.nostr.attempted).toBe(false) 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)) fetchMock.mockResolvedValueOnce(okJson(ticket));
const legacy = await api.resendTicketEmail('tid', 'admin') const legacy = await api.resendTicketEmail("tid", "admin");
expect(legacy.ticket.id).toBe('tid') expect(legacy.ticket.id).toBe("tid");
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null }) expect(legacy.email).toEqual({
}) attempted: false,
}) sent: false,
error: null,
});
});
});
describe('TicketApiService promo codes', () => { describe("TicketApiService promo codes", () => {
const fetchMock = vi.fn() const fetchMock = vi.fn();
let api: TicketApiService let api: TicketApiService;
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset() fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
}) });
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals());
function lastCall() { function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] 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> } return {
url,
init,
body: JSON.parse(init.body as string),
headers: init.headers as Record<string, string>,
};
} }
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => { it("validatePromoCode posts the v2 shape with an upper-cased code", async () => {
const totals = { const totals = {
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat', subtotal: 2000,
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }], discount: 1000,
} total: 1000,
fetchMock.mockResolvedValue(okJson(totals)) currency: "sat",
const res = await api.validatePromoCode('evt', ' half ', 2) discounts_applied: [
const { url, init, body } = lastCall() {
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`) code: "HALF",
expect(init.method).toBe('POST') discount_percent: 50,
expect(body).toEqual({ codes: ['HALF'], quantity: 2 }) discount_fixed: null,
expect(res).toEqual(totals) 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 () => { 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) fetchMock.mockResolvedValue({
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 }) 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 () => { it("requestTicket forwards promo_code verbatim", async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) fetchMock.mockResolvedValue(
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 }) okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 }) );
}) 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 () => { it("requestTicket names the chosen ticket wave", async () => {
fetchMock.mockResolvedValue(okJson({ id: 'evt' })) // Without it the backend refuses ("Please select a ticket wave")
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }] // whenever more than one wave is open.
fetchMock.mockResolvedValue(
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
);
await api.requestTicket("evt", { userId: "u1", ticketWaveId: "regular" });
expect(lastCall().body).toEqual({
user_id: "u1",
ticket_wave_id: "regular",
});
});
it("requestTicket omits the wave when none was chosen", async () => {
// A single open wave is implied server-side; sending an empty value
// would be rejected as naming a wave that does not exist.
fetchMock.mockResolvedValue(
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
);
await api.requestTicket("evt", { userId: "u1" });
expect(lastCall().body).not.toHaveProperty("ticket_wave_id");
});
it("validatePromoCode prices against the same wave the purchase will", async () => {
fetchMock.mockResolvedValue(
okJson({ subtotal: 50, discount: 0, total: 50, currency: "sat" }),
);
await api.validatePromoCode("evt", "HALF", 2, "regular");
expect(lastCall().body).toEqual({
codes: ["HALF"],
quantity: 2,
ticket_wave_id: "regular",
});
});
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( await api.createEvent(
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } }, { name: "E", event_start_date: "2030-01-01", extra: { promo_codes } },
'inkey', "inkey",
) );
const { body, headers } = lastCall() const { body, headers } = lastCall();
expect(body.extra.promo_codes).toEqual(promo_codes) expect(body.extra.promo_codes).toEqual(promo_codes);
expect(headers['X-API-KEY']).toBe('inkey') expect(headers["X-API-KEY"]).toBe("inkey");
}) });
}) });

View file

@ -11,33 +11,36 @@ import type {
TicketedEvent, TicketedEvent,
CreateEventRequest, CreateEventRequest,
PromoValidateRequest, PromoValidateRequest,
} from '../types/ticket' } from "../types/ticket";
import { normalizePromoCode } from '../lib/promoCodes' import { normalizePromoCode } from "../lib/promoCodes";
export interface TicketApiConfig { export interface TicketApiConfig {
baseUrl: string baseUrl: string;
apiKey: string apiKey: string;
} }
/** Options for `TicketApiService.requestTicket`. */ /** Options for `TicketApiService.requestTicket`. */
export interface RequestTicketOptions { export interface RequestTicketOptions {
/** LNbits user id — logged-in buyers. */ /** LNbits user id — logged-in buyers. */
userId?: string userId?: string;
/** Guest buyer name (required together with `email` when no `userId`). */ /** Guest buyer name (required together with `email` when no `userId`). */
name?: string name?: string;
/** Delivery email — required for guests, optional for logged-in buyers. */ /** Delivery email — required for guests, optional for logged-in buyers. */
email?: string email?: string;
/** Bearer token; forwarded only when present. */ /** Bearer token; forwarded only when present. */
accessToken?: string accessToken?: string;
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */ /** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
frontendUrl?: string frontendUrl?: string;
paymentMethod?: PaymentMethod paymentMethod?: PaymentMethod;
fiatProvider?: string fiatProvider?: string;
promoCode?: string promoCode?: string;
refundAddress?: string refundAddress?: string;
nostrIdentifier?: string nostrIdentifier?: string;
/** Number of tickets to buy on this invoice. Backend caps at 10. */ /** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number quantity?: number;
/** Which pricing tier to buy from. Required when several waves are
* open — the backend refuses to guess. */
ticketWaveId?: string;
} }
/** /**
@ -56,11 +59,10 @@ export class TicketApiService {
* Used to correlate Nostr events with ticketed events. * Used to correlate Nostr events with ticketed events.
*/ */
async fetchTicketedEvents(): Promise<any[]> { async fetchTicketedEvents(): Promise<any[]> {
const response = await this.request( const response = await this.request("/events/api/v1/events/public", {
'/events/api/v1/events/public', method: "GET",
{ method: 'GET' } });
) return response;
return response
} }
/** /**
@ -71,10 +73,10 @@ export class TicketApiService {
* own event the moment it drops to `proposed`. * own event the moment it drops to `proposed`.
*/ */
async fetchMyEvents(invoiceKey: string): Promise<any[]> { async fetchMyEvents(invoiceKey: string): Promise<any[]> {
return this.request('/events/api/v1/events?all_wallets=true', { return this.request("/events/api/v1/events?all_wallets=true", {
method: 'GET', method: "GET",
headers: { 'X-API-KEY': invoiceKey }, headers: { "X-API-KEY": invoiceKey },
}) });
} }
/** /**
@ -99,29 +101,32 @@ export class TicketApiService {
eventId: string, eventId: string,
options: RequestTicketOptions = {}, options: RequestTicketOptions = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = {} const body: CreateTicketRequest = {};
if (options.userId) body.user_id = options.userId if (options.userId) body.user_id = options.userId;
if (options.name) body.name = options.name if (options.name) body.name = options.name;
if (options.email) body.email = options.email if (options.email) body.email = options.email;
if (options.frontendUrl) body.frontend_url = options.frontendUrl if (options.frontendUrl) body.frontend_url = options.frontendUrl;
if (options.paymentMethod) body.payment_method = options.paymentMethod if (options.paymentMethod) body.payment_method = options.paymentMethod;
if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.fiatProvider) body.fiat_provider = options.fiatProvider;
if (options.promoCode) body.promo_code = options.promoCode if (options.promoCode) body.promo_code = options.promoCode;
if (options.refundAddress) body.refund_address = options.refundAddress if (options.refundAddress) body.refund_address = options.refundAddress;
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.nostrIdentifier)
if (options.quantity && options.quantity > 1) body.quantity = options.quantity body.nostr_identifier = options.nostrIdentifier;
if (options.quantity && options.quantity > 1)
body.quantity = options.quantity;
if (options.ticketWaveId) body.ticket_wave_id = options.ticketWaveId;
const headers: Record<string, string> = { 'Content-Type': 'application/json' } const headers: Record<string, string> = {
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}` "Content-Type": "application/json",
};
if (options.accessToken)
headers["Authorization"] = `Bearer ${options.accessToken}`;
const data = await this.request( const data = await this.request(`/events/api/v1/tickets/${eventId}`, {
`/events/api/v1/tickets/${eventId}`, method: "POST",
{ headers,
method: 'POST', body: JSON.stringify(body),
headers, });
body: JSON.stringify(body),
}
)
return { return {
paymentHash: data.payment_hash, paymentHash: data.payment_hash,
@ -133,7 +138,7 @@ export class TicketApiService {
// and no invoice to settle. // and no invoice to settle.
paid: Boolean(data.paid), paid: Boolean(data.paid),
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined, ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} };
} }
/** /**
@ -142,7 +147,9 @@ export class TicketApiService {
* the public ticket page the emailed link opens. * the public ticket page the emailed link opens.
*/ */
async getTicket(ticketId: string): Promise<PublicTicket> { async getTicket(ticketId: string): Promise<PublicTicket> {
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' }) const t = await this.request(`/events/api/v1/tickets/${ticketId}`, {
method: "GET",
});
return { return {
eventId: t.event, eventId: t.event,
name: t.name ?? null, name: t.name ?? null,
@ -150,7 +157,7 @@ export class TicketApiService {
paid: Boolean(t.paid), paid: Boolean(t.paid),
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
} };
} }
/** /**
@ -160,7 +167,7 @@ export class TicketApiService {
* failure here as non-fatal. * failure here as non-fatal.
*/ */
async getPublicEvent(eventId: string): Promise<TicketedEvent> { async getPublicEvent(eventId: string): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' }) return this.request(`/events/api/v1/events/${eventId}`, { method: "GET" });
} }
/** /**
@ -168,18 +175,18 @@ export class TicketApiService {
*/ */
async checkPaymentStatus( async checkPaymentStatus(
eventId: string, eventId: string,
paymentHash: string paymentHash: string,
): Promise<TicketPaymentStatus> { ): Promise<TicketPaymentStatus> {
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}/${paymentHash}`, `/events/api/v1/tickets/${eventId}/${paymentHash}`,
{ method: 'POST' } { method: "POST" },
) );
return { return {
paid: data.paid === true, paid: data.paid === true,
ticketId: data.ticket_id, ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined, ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} };
} }
/** /**
@ -187,19 +194,16 @@ export class TicketApiService {
*/ */
async fetchUserTickets( async fetchUserTickets(
userId: string, userId: string,
accessToken: string accessToken: string,
): Promise<EventTicket[]> { ): Promise<EventTicket[]> {
const data = await this.request( const data = await this.request(`/events/api/v1/tickets/user/${userId}`, {
`/events/api/v1/tickets/user/${userId}`, method: "GET",
{ headers: {
method: 'GET', Authorization: `Bearer ${accessToken}`,
headers: { },
'Authorization': `Bearer ${accessToken}`, });
},
}
)
return (data as any[]).map(t => ({ return (data as any[]).map((t) => ({
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -211,7 +215,7 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
})) }));
} }
/** /**
@ -220,10 +224,10 @@ export class TicketApiService {
async validateTicket(ticketId: string): Promise<EventTicket[]> { async validateTicket(ticketId: string): Promise<EventTicket[]> {
const data = await this.request( const data = await this.request(
`/events/api/v1/register/ticket/${ticketId}`, `/events/api/v1/register/ticket/${ticketId}`,
{ method: 'GET' } { method: "GET" },
) );
return (data as any[]).map(t => ({ return (data as any[]).map((t) => ({
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -235,22 +239,25 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
})) }));
} }
/** /**
* Create a new event. Uses invoice key (any user). * Create a new event. Uses invoice key (any user).
* Backend determines approval status based on user role and settings. * Backend determines approval status based on user role and settings.
*/ */
async createEvent(eventData: CreateEventRequest, invoiceKey: string): Promise<TicketedEvent> { async createEvent(
return this.request('/events/api/v1/events', { eventData: CreateEventRequest,
method: 'POST', invoiceKey: string,
): Promise<TicketedEvent> {
return this.request("/events/api/v1/events", {
method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-API-KEY': invoiceKey, "X-API-KEY": invoiceKey,
}, },
body: JSON.stringify(eventData), body: JSON.stringify(eventData),
}) });
} }
/** /**
@ -265,13 +272,13 @@ export class TicketApiService {
adminKey: string, adminKey: string,
): Promise<TicketedEvent> { ): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { return this.request(`/events/api/v1/events/${eventId}`, {
method: 'PUT', method: "PUT",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-API-KEY': adminKey, "X-API-KEY": adminKey,
}, },
body: JSON.stringify(eventData), body: JSON.stringify(eventData),
}) });
} }
/** /**
@ -289,16 +296,16 @@ export class TicketApiService {
const r = await this.request( const r = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`, `/events/api/v1/tickets/${ticketId}/resend-email`,
{ {
method: 'POST', method: "POST",
headers: { 'X-API-KEY': adminKey }, headers: { "X-API-KEY": adminKey },
} },
) );
const t = r.ticket ?? r const t = r.ticket ?? r;
const delivery = (d: any) => ({ const delivery = (d: any) => ({
attempted: Boolean(d?.attempted), attempted: Boolean(d?.attempted),
sent: Boolean(d?.sent), sent: Boolean(d?.sent),
error: d?.error ?? null, error: d?.error ?? null,
}) });
return { return {
ticket: { ticket: {
id: t.id, id: t.id,
@ -315,7 +322,7 @@ export class TicketApiService {
}, },
email: delivery(r.email), email: delivery(r.email),
nostr: delivery(r.nostr), nostr: delivery(r.nostr),
} };
} }
/** /**
@ -329,23 +336,23 @@ export class TicketApiService {
eventId: string, eventId: string,
adminKey: string, adminKey: string,
): Promise<{ ): Promise<{
event_id: string event_id: string;
sold: number sold: number;
registered: number registered: number;
remaining: number remaining: number;
tickets: Array<{ tickets: Array<{
id: string id: string;
name?: string | null name?: string | null;
registered: boolean registered: boolean;
registered_at: string | null registered_at: string | null;
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */ /** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
applied_promo_code?: string | null applied_promo_code?: string | null;
}> }>;
}> { }> {
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, { return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
method: 'GET', method: "GET",
headers: { 'X-API-KEY': adminKey }, headers: { "X-API-KEY": adminKey },
}) });
} }
/** /**
@ -355,13 +362,24 @@ export class TicketApiService {
* means "not valid". Older extensions 404 — the thrown error carries * means "not valid". Older extensions 404 — the thrown error carries
* `status` so callers can fall back to "applied at checkout". * `status` so callers can fall back to "applied at checkout".
*/ */
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> { async validatePromoCode(
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity } eventId: string,
code: string,
quantity = 1,
ticketWaveId?: string,
): Promise<BasketTotals> {
// The preview must name the same wave the purchase will, or the quote
// and the charge are priced against different tiers.
const body: PromoValidateRequest = {
codes: [normalizePromoCode(code)],
quantity,
};
if (ticketWaveId) body.ticket_wave_id = ticketWaveId;
return this.request(`/events/api/v1/promo/validate/${eventId}`, { return this.request(`/events/api/v1/promo/validate/${eventId}`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}) });
} }
/** /**
@ -370,11 +388,17 @@ export class TicketApiService {
* unpaid / already-registered / not-owned cases with HTTP errors * unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message. * whose `detail` becomes the thrown Error message.
*/ */
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> { async registerTicket(
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, { ticketId: string,
method: 'PUT', adminKey: string,
headers: { 'X-API-KEY': adminKey }, ): Promise<EventTicket> {
}) const t = await this.request(
`/events/api/v1/tickets/register/${ticketId}`,
{
method: "PUT",
headers: { "X-API-KEY": adminKey },
},
);
return { return {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
@ -387,7 +411,7 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
} };
} }
/** /**
@ -397,13 +421,13 @@ export class TicketApiService {
*/ */
async isAdmin(adminKey: string): Promise<boolean> { async isAdmin(adminKey: string): Promise<boolean> {
try { try {
await this.request('/events/api/v1/events/all', { await this.request("/events/api/v1/events/all", {
method: 'GET', method: "GET",
headers: { 'X-API-KEY': adminKey }, headers: { "X-API-KEY": adminKey },
}) });
return true return true;
} catch { } catch {
return false return false;
} }
} }
@ -415,13 +439,16 @@ export class TicketApiService {
*/ */
async getAutoApprove(invoiceKey: string): Promise<boolean> { async getAutoApprove(invoiceKey: string): Promise<boolean> {
try { try {
const settings = await this.request('/events/api/v1/events/settings/public', { const settings = await this.request(
method: 'GET', "/events/api/v1/events/settings/public",
headers: { 'X-API-KEY': invoiceKey }, {
}) method: "GET",
return Boolean(settings?.auto_approve) headers: { "X-API-KEY": invoiceKey },
},
);
return Boolean(settings?.auto_approve);
} catch { } catch {
return false return false;
} }
} }
@ -430,13 +457,15 @@ export class TicketApiService {
*/ */
async getCurrencies(): Promise<string[]> { async getCurrencies(): Promise<string[]> {
try { try {
const currencies = await this.request('/api/v1/currencies', { method: 'GET' }) const currencies = await this.request("/api/v1/currencies", {
method: "GET",
});
if (Array.isArray(currencies)) { if (Array.isArray(currencies)) {
return ['sats', ...currencies.filter((c: string) => c !== 'sats')] return ["sats", ...currencies.filter((c: string) => c !== "sats")];
} }
return ['sats'] return ["sats"];
} catch { } catch {
return ['sats'] return ["sats"];
} }
} }
@ -445,28 +474,31 @@ export class TicketApiService {
*/ */
private async request(path: string, init: RequestInit = {}): Promise<any> { private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'accept': 'application/json', accept: "application/json",
'X-API-KEY': this.config.apiKey, "X-API-KEY": this.config.apiKey,
...(init.headers as Record<string, string> ?? {}), ...((init.headers as Record<string, string>) ?? {}),
} };
const response = await fetch(`${this.config.baseUrl}${path}`, { const response = await fetch(`${this.config.baseUrl}${path}`, {
...init, ...init,
headers, headers,
}) });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` })) const error = await response
const errorMessage = typeof error.detail === 'string' .json()
? error.detail .catch(() => ({ detail: `Request failed: ${path}` }));
: Array.isArray(error.detail) const errorMessage =
? error.detail[0]?.msg ?? 'Request failed' typeof error.detail === "string"
: 'Request failed' ? error.detail
: Array.isArray(error.detail)
? (error.detail[0]?.msg ?? "Request failed")
: "Request failed";
// Carry the HTTP status so callers can tell "route missing on an // Carry the HTTP status so callers can tell "route missing on an
// older extension" (404) apart from "bad input" (400). // older extension" (404) apart from "bad input" (400).
throw Object.assign(new Error(errorMessage), { status: response.status }) throw Object.assign(new Error(errorMessage), { status: response.status });
} }
return response.json() return response.json();
} }
} }

View file

@ -1,7 +1,7 @@
import ngeohash from 'ngeohash' import ngeohash from "ngeohash";
import type { EventCategory } from './category' import type { EventCategory } from "./category";
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from "./nip52";
import type { TicketedEvent } from './ticket' import type { TicketedEvent } from "./ticket";
/** /**
* Unified view model for displaying events in the UI. * Unified view model for displaying events in the UI.
@ -9,43 +9,43 @@ import type { TicketedEvent } from './ticket'
*/ */
export interface Event { export interface Event {
/** Unique identifier (NIP-52 d-tag) */ /** Unique identifier (NIP-52 d-tag) */
id: string id: string;
/** Nostr event ID */ /** Nostr event ID */
nostrEventId: string nostrEventId: string;
/** Whether this is a date-only or time-specific event */ /** Whether this is a date-only or time-specific event */
type: 'date' | 'time' type: "date" | "time";
/** Organizer information */ /** Organizer information */
organizer: OrganizerInfo organizer: OrganizerInfo;
/** Event title */ /** Event title */
title: string title: string;
/** Brief summary */ /** Brief summary */
summary?: string summary?: string;
/** Full description (markdown) */ /** Full description (markdown) */
description: string description: string;
/** Banner/poster image URL */ /** Banner/poster image URL */
image?: string image?: string;
/** Start date/time */ /** Start date/time */
startDate: Date startDate: Date;
/** End date/time */ /** End date/time */
endDate?: Date endDate?: Date;
/** Timezone identifier (IANA) */ /** Timezone identifier (IANA) */
timezone?: string timezone?: string;
/** Human-readable location */ /** Human-readable location */
location?: string location?: string;
/** Geographic coordinates (derived from geohash) */ /** Geographic coordinates (derived from geohash) */
coordinates?: { lat: number; lng: number } coordinates?: { lat: number; lng: number };
/** NIP-52 geohash (g tag) */ /** NIP-52 geohash (g tag) */
geohash?: string geohash?: string;
/** Primary category */ /** Primary category */
category?: EventCategory category?: EventCategory;
/** All hashtags/tags */ /** All hashtags/tags */
tags: string[] tags: string[];
/** Ticket pricing info (if ticketed) */ /** Ticket pricing info (if ticketed) */
ticketInfo?: EventTicketInfo ticketInfo?: EventTicketInfo;
/** Whether this is a private/invite-only event */ /** Whether this is a private/invite-only event */
isPrivate: boolean isPrivate: boolean;
/** Nostr event created_at timestamp */ /** Nostr event created_at timestamp */
createdAt: Date createdAt: Date;
/** /**
* LNbits approval status, when the event came from the events * LNbits approval status, when the event came from the events
* extension rather than a Nostr relay. Undefined for events * extension rather than a Nostr relay. Undefined for events
@ -53,7 +53,7 @@ export interface Event {
* events make it onto relays). Used to render a "Pending review" * events make it onto relays). Used to render a "Pending review"
* badge for the creator's own non-approved drafts. * badge for the creator's own non-approved drafts.
*/ */
lnbitsStatus?: 'approved' | 'proposed' | 'rejected' lnbitsStatus?: "approved" | "proposed" | "rejected";
/** /**
* Belongs to the current user. Set by the adapter for own LNbits * Belongs to the current user. Set by the adapter for own LNbits
* drafts and by the events-subscribe callback when the Nostr * drafts and by the events-subscribe callback when the Nostr
@ -61,59 +61,65 @@ export interface Event {
* "Yours" badge on the feed so the creator can spot their events * "Yours" badge on the feed so the creator can spot their events
* at a glance. * at a glance.
*/ */
isMine?: boolean isMine?: boolean;
} }
export interface OrganizerInfo { export interface OrganizerInfo {
pubkey: string pubkey: string;
name?: string name?: string;
picture?: string picture?: string;
nip05?: string nip05?: string;
} }
export interface EventTicketInfo { export interface EventTicketInfo {
price: number price: number;
currency: string currency: string;
/** Remaining capacity. Undefined means unlimited. */ /** Remaining capacity. Undefined means unlimited. */
available?: number available?: number;
/** Running paid count. */ /** Running paid count across every wave. */
sold: number sold: number;
/** Total capacity (available + sold). Undefined means unlimited. */
total?: number
/** Whether the organizer enabled fiat checkout. */ /** Whether the organizer enabled fiat checkout. */
allowFiat: boolean allowFiat: boolean;
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string fiatCurrency?: string;
/** Rails the organizer enabled (see TicketTags.paymentMethods). */ /** Rails the organizer enabled (see TicketTags.paymentMethods). */
paymentMethods?: string[] paymentMethods?: string[];
} }
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined { function ticketTagsToInfo(
if (!ticket) return undefined ticket: TicketTags | undefined,
): EventTicketInfo | undefined {
if (!ticket) return undefined;
return { return {
price: ticket.price, price: ticket.price,
currency: ticket.currency, currency: ticket.currency,
available: ticket.available, available: ticket.available,
sold: ticket.sold, sold: ticket.sold,
// Capacity isn't published directly; derive it from remaining + sold. // No total. It used to be derived as `available + sold`, which was
// Undefined `available` means unlimited, so total stays undefined too. // already wrong after any organizer edit re-baselined remaining
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined, // (aiolabs/webapp#143) and is now meaningless: since events ext
// v1.6.8 `tickets_available` is the ACTIVE WAVE's stock while
// `tickets_sold` counts every wave, so the two don't share a
// denominator. Nothing publishes a capacity, so nothing can show one.
allowFiat: ticket.allowFiat, allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency, fiatCurrency: ticket.fiatCurrency,
paymentMethods: ticket.paymentMethods, paymentMethods: ticket.paymentMethods,
} };
} }
/** /**
* Convert a CalendarTimeEvent to an Event view model * Convert a CalendarTimeEvent to an Event view model
*/ */
export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Event { export function calendarTimeEventToEvent(
const category = event.hashtags[0] as EventCategory | undefined event: CalendarTimeEvent,
organizer?: Partial<OrganizerInfo>,
): Event {
const category = event.hashtags[0] as EventCategory | undefined;
return { return {
id: event.dTag, id: event.dTag,
nostrEventId: event.id, nostrEventId: event.id,
type: 'time', type: "time",
organizer: { organizer: {
pubkey: event.pubkey, pubkey: event.pubkey,
...organizer, ...organizer,
@ -133,25 +139,28 @@ export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: P
ticketInfo: ticketTagsToInfo(event.ticket), ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} };
} }
/** /**
* Convert a CalendarDateEvent to an Event view model * Convert a CalendarDateEvent to an Event view model
*/ */
export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Event { export function calendarDateEventToEvent(
const category = event.hashtags[0] as EventCategory | undefined event: CalendarDateEvent,
organizer?: Partial<OrganizerInfo>,
): Event {
const category = event.hashtags[0] as EventCategory | undefined;
// Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC // Parse ISO date string (YYYY-MM-DD) to Date at midnight UTC
const parseIsoDate = (dateStr: string): Date => { const parseIsoDate = (dateStr: string): Date => {
const [year, month, day] = dateStr.split('-').map(Number) const [year, month, day] = dateStr.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day)) return new Date(Date.UTC(year, month - 1, day));
} };
return { return {
id: event.dTag, id: event.dTag,
nostrEventId: event.id, nostrEventId: event.id,
type: 'date', type: "date",
organizer: { organizer: {
pubkey: event.pubkey, pubkey: event.pubkey,
...organizer, ...organizer,
@ -170,7 +179,7 @@ export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: P
ticketInfo: ticketTagsToInfo(event.ticket), ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} };
} }
/** /**
@ -189,18 +198,18 @@ export function ticketedEventToEvent(
event: TicketedEvent, event: TicketedEvent,
organizer?: Partial<OrganizerInfo>, organizer?: Partial<OrganizerInfo>,
): Event { ): Event {
const hasTime = event.event_start_date.includes('T') const hasTime = event.event_start_date.includes("T");
const startDate = hasTime const startDate = hasTime
? new Date(event.event_start_date) ? new Date(event.event_start_date)
: parseDateOnly(event.event_start_date) : parseDateOnly(event.event_start_date);
const endRaw = event.event_end_date const endRaw = event.event_end_date;
const endDate = endRaw const endDate = endRaw
? endRaw.includes('T') ? endRaw.includes("T")
? new Date(endRaw) ? new Date(endRaw)
: parseDateOnly(endRaw) : parseDateOnly(endRaw)
: undefined : undefined;
const category = event.categories?.[0] as EventCategory | undefined const category = event.categories?.[0] as EventCategory | undefined;
return { return {
id: event.id, id: event.id,
@ -208,16 +217,16 @@ export function ticketedEventToEvent(
// id as a placeholder. Approved + published versions will overwrite // id as a placeholder. Approved + published versions will overwrite
// this with the real Nostr event id. // this with the real Nostr event id.
nostrEventId: event.id, nostrEventId: event.id,
type: hasTime ? 'time' : 'date', type: hasTime ? "time" : "date",
organizer: { organizer: {
// Pending events have no Nostr pubkey yet. Empty string is fine // Pending events have no Nostr pubkey yet. Empty string is fine
// — the card layer falls back gracefully and the OrganizerCard // — the card layer falls back gracefully and the OrganizerCard
// is only shown for approved (Nostr-sourced) events anyway. // is only shown for approved (Nostr-sourced) events anyway.
pubkey: '', pubkey: "",
...organizer, ...organizer,
}, },
title: event.name, title: event.name,
description: event.info ?? '', description: event.info ?? "",
image: event.banner ?? undefined, image: event.banner ?? undefined,
startDate, startDate,
endDate, endDate,
@ -229,24 +238,26 @@ export function ticketedEventToEvent(
// FastAPI serialization). new Date() handles both ISO strings and // FastAPI serialization). new Date() handles both ISO strings and
// numeric epoch — same shape used in useEvents sorting. // numeric epoch — same shape used in useEvents sorting.
createdAt: new Date(event.time) || new Date(), createdAt: new Date(event.time) || new Date(),
lnbitsStatus: event.status as Event['lnbitsStatus'], lnbitsStatus: event.status as Event["lnbitsStatus"],
// fetchMyEvents only returns the caller's own events, so anything // fetchMyEvents only returns the caller's own events, so anything
// reaching this adapter is by definition mine. // reaching this adapter is by definition mine.
isMine: true, isMine: true,
} };
} }
function parseDateOnly(dateStr: string): Date { function parseDateOnly(dateStr: string): Date {
const [year, month, day] = dateStr.split('-').map(Number) const [year, month, day] = dateStr.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day)) return new Date(Date.UTC(year, month - 1, day));
} }
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined { function decodeGeohash(
if (!geohash) return undefined geohash?: string,
): { lat: number; lng: number } | undefined {
if (!geohash) return undefined;
try { try {
const { latitude, longitude } = ngeohash.decode(geohash) const { latitude, longitude } = ngeohash.decode(geohash);
return { lat: latitude, lng: longitude } return { lat: latitude, lng: longitude };
} catch { } catch {
return undefined return undefined;
} }
} }

View file

@ -7,41 +7,45 @@
*/ */
export interface PromoCode { export interface PromoCode {
code: string code: string;
discount_percent: number discount_percent: number;
active: boolean active: boolean;
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12, /** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
* same name as upstream v2). */ * same name as upstream v2). */
max_uses?: number | null max_uses?: number | null;
/** Paid tickets that used this code — derived by the backend on read, /** Paid tickets that used this code — derived by the backend on read,
* never sent back. */ * never sent back. */
used_count?: number used_count?: number;
} }
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream /** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
* v2 shape; `quantity` stands in for v2's `items` since this fork has * v2 shape; `quantity` stands in for v2's `items` since this fork has
* no ticket types). */ * no ticket types). */
export interface PromoValidateRequest { export interface PromoValidateRequest {
codes: string[] codes: string[];
quantity: number quantity: number;
/** Which wave to price against. Omit only when a single wave is open —
* the preview must name the same wave the purchase will, or the quote
* and the charge disagree. */
ticket_wave_id?: string;
} }
export interface BasketDiscount { export interface BasketDiscount {
code: string code: string;
discount_percent: number | null discount_percent: number | null;
/** Always null on this fork (percent-only); kept for the v2 shape. */ /** Always null on this fork (percent-only); kept for the v2 shape. */
discount_fixed: number | null discount_fixed: number | null;
amount_saved: number amount_saved: number;
} }
/** Response of the promo validate endpoint. An empty `discounts_applied` /** Response of the promo validate endpoint. An empty `discounts_applied`
* means none of the submitted codes is valid for this event/quantity. */ * means none of the submitted codes is valid for this event/quantity. */
export interface BasketTotals { export interface BasketTotals {
subtotal: number subtotal: number;
discount: number discount: number;
total: number total: number;
discounts_applied: BasketDiscount[] discounts_applied: BasketDiscount[];
currency: string currency: string;
} }
/** /**
@ -50,73 +54,101 @@ export interface BasketTotals {
* and the per-event notification toggles + custom subject/body added * and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0. * in upstream v1.4.0 (PR #50) and v1.6.0.
*/ */
/**
* A time-boxed pricing tier (events ext >= v1.6.8).
*
* Price, currency, stock and the fiat flags belong to the wave, not the
* event: `sync_event_ticket_waves` makes the event-level fields derived —
* `amount_tickets` is the sum across waves, `price_per_ticket` and
* `currency` are the FIRST wave's. Never price or count from the event.
*/
export interface TicketWave {
id: string;
title: string;
/** YYYY-MM-DD. Day granularity — a wave opens and closes on whole days. */
opening_date: string;
closing_date: string;
currency: string;
use_ticket_image?: boolean;
ticket_image_id?: string | null;
allow_fiat?: boolean;
fiat_currency?: string;
/** Remaining stock in this wave, decremented per sale. */
amount_tickets: number;
price_per_ticket: number;
}
export interface EventExtra { export interface EventExtra {
/** Present on organizer-authenticated records only: public event /** Present on organizer-authenticated records only: public event
* responses (`/events/public`, `/events/{id}`) no longer carry the * responses (`/events/public`, `/events/{id}`) no longer carry the
* code list (events ext >= v1.6.1-aio.12). */ * code list (events ext >= v1.6.1-aio.12). */
promo_codes?: PromoCode[] promo_codes?: PromoCode[];
conditional: boolean conditional: boolean;
min_tickets: number min_tickets: number;
email_notifications: boolean email_notifications: boolean;
nostr_notifications: boolean nostr_notifications: boolean;
notification_subject: string notification_subject: string;
notification_body: string notification_body: string;
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent /** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
* means the legacy rule: Lightning always, fiat iff `allow_fiat`. * means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */ * Same shape as upstream v2's `extra.payment_methods`. */
payment_methods?: string[] payment_methods?: string[];
/** Pricing tiers. Present on public responses too (events ext
* >= v1.6.8-aio.2) so a buyer can pick one. Absent on events from
* before waves existed — treat that as a single implied wave. */
ticket_waves?: TicketWave[];
} }
export interface EventTicketExtra { export interface EventTicketExtra {
applied_promo_code?: string | null applied_promo_code?: string | null;
sats_paid?: number | null sats_paid?: number | null;
refund_address?: string | null refund_address?: string | null;
nostr_identifier?: string | null nostr_identifier?: string | null;
ticket_base_url?: string | null ticket_base_url?: string | null;
email_notification_sent: boolean email_notification_sent: boolean;
nostr_notification_sent: boolean nostr_notification_sent: boolean;
refunded: boolean refunded: boolean;
} }
export interface EventTicket { export interface EventTicket {
id: string id: string;
wallet: string wallet: string;
/** Reference to the event (LNbits event ID) */ /** Reference to the event (LNbits event ID) */
eventId: string eventId: string;
/** Ticket holder name */ /** Ticket holder name */
name: string | null name: string | null;
/** Ticket holder email */ /** Ticket holder email */
email: string | null email: string | null;
/** Nostr pubkey or LNbits user ID */ /** Nostr pubkey or LNbits user ID */
userId: string | null userId: string | null;
/** Whether ticket has been scanned/registered at the door */ /** Whether ticket has been scanned/registered at the door */
registered: boolean registered: boolean;
/** Whether payment has been confirmed */ /** Whether payment has been confirmed */
paid: boolean paid: boolean;
/** Ticket creation timestamp */ /** Ticket creation timestamp */
time: string time: string;
/** Registration/scan timestamp */ /** Registration/scan timestamp */
regTimestamp: string regTimestamp: string;
/** Optional metadata — promo code applied, sats paid, notification /** Optional metadata — promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */ * delivery flags, refund state. May be absent on older tickets. */
extra?: EventTicketExtra extra?: EventTicketExtra;
} }
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' export type TicketStatus = "pending" | "paid" | "registered" | "cancelled";
export type PaymentMethod = 'lightning' | 'fiat' export type PaymentMethod = "lightning" | "fiat";
export interface TicketPurchaseRequest { export interface TicketPurchaseRequest {
eventId: string eventId: string;
userId: string userId: string;
accessToken: string accessToken: string;
/** Lightning (default) or fiat. Only meaningful if the event has /** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces * `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */ * to lightning. */
paymentMethod?: PaymentMethod paymentMethod?: PaymentMethod;
/** Specific fiat provider id (e.g. "stripe"). Backend picks the /** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */ * user's default if omitted. */
fiatProvider?: string fiatProvider?: string;
} }
/** /**
@ -128,26 +160,26 @@ export interface TicketPurchaseRequest {
* scannable rows so the client skips the payment step entirely. * scannable rows so the client skips the payment step entirely.
*/ */
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string;
paymentRequest?: string paymentRequest?: string;
fiatPaymentRequest?: string fiatPaymentRequest?: string;
fiatProvider?: string fiatProvider?: string;
isFiat: boolean isFiat: boolean;
/** Free tickets: already issued + paid, no invoice to settle. */ /** Free tickets: already issued + paid, no invoice to settle. */
paid?: boolean paid?: boolean;
/** Row ids returned inline for the free path (no poll needed). */ /** Row ids returned inline for the free path (no poll needed). */
ticketIds?: string[] ticketIds?: string[];
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
paid: boolean paid: boolean;
/** First ticket id created on this invoice. Back-compat with /** First ticket id created on this invoice. Back-compat with
* single-ticket purchases — equals the payment_hash. */ * single-ticket purchases — equals the payment_hash. */
ticketId?: string ticketId?: string;
/** Every row created on this invoice — one for single-ticket /** Every row created on this invoice — one for single-ticket
* purchases, N for multi-ticket. Each row is independently * purchases, N for multi-ticket. Each row is independently
* scannable at the door. */ * scannable at the door. */
ticketIds?: string[] ticketIds?: string[];
} }
/** /**
@ -159,48 +191,48 @@ export interface TicketPaymentStatus {
* switches the publisher between NIP-52 kind 31922 and 31923. * switches the publisher between NIP-52 kind 31922 and 31923.
*/ */
export interface TicketedEvent { export interface TicketedEvent {
id: string id: string;
wallet: string wallet: string;
name: string name: string;
info: string info: string;
closing_date: string | null closing_date: string | null;
event_start_date: string event_start_date: string;
event_end_date: string | null event_end_date: string | null;
currency: string currency: string;
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */ /** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean allow_fiat: boolean;
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */ /** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string fiat_currency: string;
amount_tickets: number amount_tickets: number;
price_per_ticket: number price_per_ticket: number;
time: string time: string;
sold: number sold: number;
banner: string | null banner: string | null;
location: string | null location: string | null;
categories: string[] categories: string[];
extra: EventExtra extra: EventExtra;
status: string status: string;
} }
export interface CreateEventRequest { export interface CreateEventRequest {
wallet?: string wallet?: string;
name: string name: string;
info?: string info?: string;
closing_date?: string closing_date?: string;
event_start_date: string event_start_date: string;
event_end_date?: string event_end_date?: string;
currency?: string currency?: string;
allow_fiat?: boolean allow_fiat?: boolean;
fiat_currency?: string fiat_currency?: string;
amount_tickets?: number amount_tickets?: number;
price_per_ticket?: number price_per_ticket?: number;
banner?: string | null banner?: string | null;
location?: string | null location?: string | null;
categories?: string[] categories?: string[];
/** Optional — notification toggles + custom subject/body, promo /** Optional — notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh * codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */ * EventExtra if omitted. */
extra?: Partial<EventExtra> extra?: Partial<EventExtra>;
} }
/** /**
@ -214,19 +246,23 @@ export interface CreateEventRequest {
* under the webapp instead of the LNbits host. * under the webapp instead of the LNbits host.
*/ */
export interface CreateTicketRequest { export interface CreateTicketRequest {
name?: string name?: string;
email?: string email?: string;
user_id?: string user_id?: string;
/** App root (origin + base path, no trailing slash). Origin must be /** App root (origin + base path, no trailing slash). Origin must be
* on the LNbits instance's CORS allowlist or the backend 400s. */ * on the LNbits instance's CORS allowlist or the backend 400s. */
frontend_url?: string frontend_url?: string;
promo_code?: string promo_code?: string;
refund_address?: string refund_address?: string;
nostr_identifier?: string nostr_identifier?: string;
payment_method?: PaymentMethod payment_method?: PaymentMethod;
fiat_provider?: string fiat_provider?: string;
/** Number of tickets on this invoice (backend bounds 1..10). */ /** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number quantity?: number;
/** Which pricing tier the buyer chose. Required whenever more than one
* wave is open — the backend refuses to guess ("Please select a ticket
* wave") — and implied when exactly one is. */
ticket_wave_id?: string;
} }
/** /**
@ -235,18 +271,18 @@ export interface CreateTicketRequest {
* the caller asked for. * the caller asked for.
*/ */
export interface PublicTicket { export interface PublicTicket {
eventId: string eventId: string;
name: string | null name: string | null;
registered: boolean registered: boolean;
paid: boolean paid: boolean;
time: string time: string;
regTimestamp: string regTimestamp: string;
} }
export interface NotificationDeliveryResult { export interface NotificationDeliveryResult {
attempted: boolean attempted: boolean;
sent: boolean sent: boolean;
error: string | null error: string | null;
} }
/** /**
@ -254,7 +290,7 @@ export interface NotificationDeliveryResult {
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8). * (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
*/ */
export interface TicketResendResult { export interface TicketResendResult {
ticket: EventTicket ticket: EventTicket;
email: NotificationDeliveryResult email: NotificationDeliveryResult;
nostr: NotificationDeliveryResult nostr: NotificationDeliveryResult;
} }

View file

@ -1,117 +1,157 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from "vue";
import { useElementVisibility } from '@vueuse/core' import { useElementVisibility } from "@vueuse/core";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router";
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
import { format } from 'date-fns' import { format } from "date-fns";
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from "../composables/useDateLocale";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Separator } from '@/components/ui/separator' import { Separator } from "@/components/ui/separator";
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History, Calendar,
} from 'lucide-vue-next' MapPin,
import { useEventDetail } from '../composables/useEventDetail' ArrowLeft,
import BookmarkButton from '../components/BookmarkButton.vue' Pencil,
import OrganizerCard from '../components/OrganizerCard.vue' Ticket,
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' CheckCircle2,
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue' ScanLine,
import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout' History,
import { useAuth } from '@/composables/useAuthService' } from "lucide-vue-next";
import { useEventsStore } from '../stores/events' import { useEventDetail } from "../composables/useEventDetail";
import { useOwnedTickets } from '../composables/useOwnedTickets' import BookmarkButton from "../components/BookmarkButton.vue";
import { toastService } from '@/core/services/ToastService' import OrganizerCard from "../components/OrganizerCard.vue";
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import PurchaseTicketDialog from "../components/PurchaseTicketDialog.vue";
import type { TicketApiService } from '../services/TicketApiService' import CheckoutReturnDialog from "../components/CheckoutReturnDialog.vue";
import type { TicketedEvent } from '../types/ticket' import {
clearPendingCheckout,
readPendingCheckout,
} from "../lib/pendingCheckout";
import { useAuth } from "@/composables/useAuthService";
import { useEventsStore } from "../stores/events";
import { useOwnedTickets } from "../composables/useOwnedTickets";
import { toastService } from "@/core/services/ToastService";
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { TicketApiService } from "../services/TicketApiService";
import type { TicketedEvent } from "../types/ticket";
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const { t } = useI18n() const { t } = useI18n();
const eventId = route.params.id as string const eventId = route.params.id as string;
const { event, isLoading, error, reload } = useEventDetail(eventId) const { event, isLoading, error, reload } = useEventDetail(eventId);
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale();
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is // Owner-edit affordance: the NIP-52 d-tag we use for the event id is
// the same as the LNbits event id (set at publish time in // the same as the LNbits event id (set at publish time in
// nostr_publisher.build_nip52_event). Look the user's own events up // nostr_publisher.build_nip52_event). Look the user's own events up
// once and offer an Edit button on a match. // once and offer an Edit button on a match.
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth();
const eventsStore = useEventsStore() const eventsStore = useEventsStore();
const ownedLnbitsEvent = ref<TicketedEvent | null>(null) const ownedLnbitsEvent = ref<TicketedEvent | null>(null);
async function loadOwnedEvent() { async function loadOwnedEvent() {
ownedLnbitsEvent.value = null ownedLnbitsEvent.value = null;
if (!isAuthenticated.value) return if (!isAuthenticated.value) return;
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey const invoiceKey = currentUser.value?.wallets?.[0]?.inkey;
if (!invoiceKey) return if (!invoiceKey) return;
try { try {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(
const mine = await ticketApi.fetchMyEvents(invoiceKey) SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
const mine = await ticketApi.fetchMyEvents(invoiceKey);
ownedLnbitsEvent.value = ownedLnbitsEvent.value =
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null (mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null;
} catch { } catch {
ownedLnbitsEvent.value = null ownedLnbitsEvent.value = null;
} }
} }
onMounted(loadOwnedEvent) // Ticket waves live on the LNbits record, not in the NIP-52 tags: the
watch(isAuthenticated, () => loadOwnedEvent()) // published tags describe the ACTIVE wave but carry no wave id, and a
// buyer needs one to choose a tier. Fetched anonymously and treated as
// non-fatal — the backend answers 410 for sold-out or closed events,
// where there is nothing to pick anyway.
const publicLnbitsEvent = ref<TicketedEvent | null>(null);
async function loadPublicEvent() {
try {
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
publicLnbitsEvent.value = await ticketApi.getPublicEvent(eventId);
} catch {
publicLnbitsEvent.value = null;
}
}
/** Whichever LNbits record we have; the organizer's carries the same waves. */
const lnbitsEvent = computed<TicketedEvent | null>(
() => ownedLnbitsEvent.value ?? publicLnbitsEvent.value,
);
onMounted(loadOwnedEvent);
onMounted(loadPublicEvent);
watch(isAuthenticated, () => loadOwnedEvent());
function openEditDialog() { function openEditDialog() {
if (!ownedLnbitsEvent.value) return if (!ownedLnbitsEvent.value) return;
eventsStore.editingEvent = ownedLnbitsEvent.value eventsStore.editingEvent = ownedLnbitsEvent.value;
eventsStore.showCreateDialog = true eventsStore.showCreateDialog = true;
} }
function openScannerPage() { function openScannerPage() {
router.push({ name: 'scan-tickets', params: { eventId } }) router.push({ name: "scan-tickets", params: { eventId } });
} }
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
if (!event.value) return '' if (!event.value) return "";
const a = event.value const a = event.value;
const opts = { locale: dateLocale.value } const opts = { locale: dateLocale.value };
if (a.type === 'date') { if (a.type === "date") {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts) const start = format(a.startDate, "EEEE, MMMM d, yyyy", opts);
if (a.endDate) { if (a.endDate) {
return `${start} — ${format(a.endDate, 'EEEE, MMMM d, yyyy', opts)}` return `${start} — ${format(a.endDate, "EEEE, MMMM d, yyyy", opts)}`;
} }
return start return start;
} }
// Time-based event. If start and end share the same calendar day, // Time-based event. If start and end share the same calendar day,
// show end as time-only ("19:00 — 21:45"). Otherwise repeat the full // show end as time-only ("19:00 — 21:45"). Otherwise repeat the full
// date on the end side so a multi-day event reads unambiguously // date on the end side so a multi-day event reads unambiguously
// ("May 29 • 19:00 — May 30 • 21:45"). // ("May 29 • 19:00 — May 30 • 21:45").
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm' const FULL = "EEEE, MMMM d, yyyy \u2022 HH:mm";
const start = format(a.startDate, FULL, opts) const start = format(a.startDate, FULL, opts);
if (!a.endDate) return start if (!a.endDate) return start;
const sameDay = const sameDay =
a.startDate.getFullYear() === a.endDate.getFullYear() && a.startDate.getFullYear() === a.endDate.getFullYear() &&
a.startDate.getMonth() === a.endDate.getMonth() && a.startDate.getMonth() === a.endDate.getMonth() &&
a.startDate.getDate() === a.endDate.getDate() a.startDate.getDate() === a.endDate.getDate();
return `${start} — ${format(a.endDate, sameDay ? 'HH:mm' : FULL, opts)}` return `${start} — ${format(a.endDate, sameDay ? "HH:mm" : FULL, opts)}`;
}) });
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
if (!event.value?.category) return null if (!event.value?.category) return null;
return t(`events.categories.${event.value.category}`, event.value.category) return t(`events.categories.${event.value.category}`, event.value.category);
}) });
function goBack() { function goBack() {
router.push({ name: 'events' }) router.push({ name: "events" });
} }
// --- Ticket purchase + owned-tickets surface ---------------------- // --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets() const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets();
const ownedPaidCount = computed(() => paidCount(eventId)) const ownedPaidCount = computed(() => paidCount(eventId));
const purchaseEvent = computed(() => { const purchaseEvent = computed(() => {
const a = event.value const a = event.value;
if (!a || !a.ticketInfo) return null if (!a || !a.ticketInfo) return null;
// Nostr tags describe the active wave but name no wave id, so the
// pickable tiers come from the LNbits record when we have one. The tag
// values stay the fallback for events with no waves at all.
const lnbits = lnbitsEvent.value;
return { return {
id: a.id, id: a.id,
name: a.title, name: a.title,
@ -120,45 +160,52 @@ const purchaseEvent = computed(() => {
allow_fiat: a.ticketInfo.allowFiat, allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency, fiat_currency: a.ticketInfo.fiatCurrency,
payment_methods: a.ticketInfo.paymentMethods, payment_methods: a.ticketInfo.paymentMethods,
} // Fields the dialog synthesizes an implied wave from when an event
}) // predates waves entirely.
closing_date: lnbits?.closing_date ?? null,
event_start_date: lnbits?.event_start_date,
event_end_date: lnbits?.event_end_date ?? null,
amount_tickets: lnbits?.amount_tickets ?? a.ticketInfo.available,
extra: lnbits?.extra ? { ticket_waves: lnbits.extra.ticket_waves } : null,
};
});
// available === undefined → unlimited capacity, button always shown // available === undefined → unlimited capacity, button always shown
// available === 0 → sold out, button hidden // available === 0 → sold out, button hidden
// available > 0 → button shown // available > 0 → button shown
const canBuyTicket = computed(() => { const canBuyTicket = computed(() => {
const info = event.value?.ticketInfo const info = event.value?.ticketInfo;
if (!info) return false if (!info) return false;
return info.available === undefined || info.available > 0 return info.available === undefined || info.available > 0;
}) });
// Past events can't be bought into. The notice below replaces the // Past events can't be bought into. The notice below replaces the
// buy CTA so the flow is unambiguous — date alone is easy to miss // buy CTA so the flow is unambiguous — date alone is easy to miss
// on a long detail page. // on a long detail page.
const isPast = computed(() => { const isPast = computed(() => {
const a = event.value const a = event.value;
if (!a) return false if (!a) return false;
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate;
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false;
return end.getTime() < Date.now() return end.getTime() < Date.now();
}) });
const showPurchaseDialog = ref(false) const showPurchaseDialog = ref(false);
function openPurchaseDialog() { function openPurchaseDialog() {
// Paid tickets are open to guests (name + email identity). Free // Paid tickets are open to guests (name + email identity). Free
// claims still need an account so seats can't be drained anonymously. // claims still need an account so seats can't be drained anonymously.
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0 const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0;
if (!isAuthenticated.value && isFreeEvent) { if (!isAuthenticated.value && isFreeEvent) {
toastService.info(t('events.detail.loginToBuyTickets'), { toastService.info(t("events.detail.loginToBuyTickets"), {
action: { action: {
label: t('events.detail.logIn'), label: t("events.detail.logIn"),
onClick: () => router.push('/login'), onClick: () => router.push("/login"),
}, },
}) });
return return;
} }
showPurchaseDialog.value = true showPurchaseDialog.value = true;
} }
// --- Return from the fiat provider --------------------------------- // --- Return from the fiat provider ---------------------------------
@ -167,36 +214,37 @@ function openPurchaseDialog() {
// or ?checkout=cancelled. The query is stripped immediately so the // or ?checkout=cancelled. The query is stripped immediately so the
// ticket ids don't linger in history; the localStorage record written // ticket ids don't linger in history; the localStorage record written
// before the redirect is the fallback source and carries the email. // before the redirect is the fallback source and carries the email.
const showReturnDialog = ref(false) const showReturnDialog = ref(false);
const returnTicketIds = ref<string[]>([]) const returnTicketIds = ref<string[]>([]);
const returnEmail = ref<string | undefined>(undefined) const returnEmail = ref<string | undefined>(undefined);
function handleCheckoutReturn() { function handleCheckoutReturn() {
const status = route.query.checkout const status = route.query.checkout;
if (status !== 'success' && status !== 'cancelled') return if (status !== "success" && status !== "cancelled") return;
const pending = readPendingCheckout() const pending = readPendingCheckout();
const samePurchase = pending?.eventId === eventId const samePurchase = pending?.eventId === eventId;
void router.replace({ query: {} }) void router.replace({ query: {} });
if (status === 'cancelled') { if (status === "cancelled") {
clearPendingCheckout() clearPendingCheckout();
toastService.info(t('events.checkout.cancelled')) toastService.info(t("events.checkout.cancelled"));
return return;
} }
const fromQuery = String(route.query.tickets ?? '') const fromQuery = String(route.query.tickets ?? "")
.split(',') .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean);
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [] const ids =
if (ids.length === 0) return fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [];
returnTicketIds.value = ids if (ids.length === 0) return;
returnEmail.value = samePurchase ? pending?.email : undefined returnTicketIds.value = ids;
showReturnDialog.value = true returnEmail.value = samePurchase ? pending?.email : undefined;
showReturnDialog.value = true;
} }
onMounted(handleCheckoutReturn) onMounted(handleCheckoutReturn);
// --- Sticky buy bar -------------------------------------------------- // --- Sticky buy bar --------------------------------------------------
// The ticket block sits right under the date/location so it is above the // The ticket block sits right under the date/location so it is above the
@ -204,20 +252,22 @@ onMounted(handleCheckoutReturn)
// it leaves the viewport a compact bar with the price + the same CTA // it leaves the viewport a compact bar with the price + the same CTA
// pins above the bottom nav so the primary action is never more than a // pins above the bottom nav so the primary action is never more than a
// tap away. Hidden while a dialog is open (the dialog is the action). // tap away. Hidden while a dialog is open (the dialog is the action).
const ticketBlockRef = ref<HTMLElement | null>(null) const ticketBlockRef = ref<HTMLElement | null>(null);
const ticketBlockVisible = useElementVisibility(ticketBlockRef) const ticketBlockVisible = useElementVisibility(ticketBlockRef);
const priceLabel = computed(() => { const priceLabel = computed(() => {
const info = event.value?.ticketInfo const info = event.value?.ticketInfo;
if (!info) return '' if (!info) return "";
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}` return info.price === 0
}) ? t("events.detail.free")
: `${info.price} ${info.currency}`;
});
const buyLabel = computed(() => const buyLabel = computed(() =>
ownedPaidCount.value > 0 ownedPaidCount.value > 0
? t('events.detail.buyAnotherTicket', 'Buy another ticket') ? t("events.detail.buyAnotherTicket", "Buy another ticket")
: t('events.detail.buyTicket', 'Buy ticket'), : t("events.detail.buyTicket", "Buy ticket"),
) );
const showStickyCta = computed( const showStickyCta = computed(
() => () =>
@ -228,22 +278,25 @@ const showStickyCta = computed(
!ticketBlockVisible.value && !ticketBlockVisible.value &&
!showPurchaseDialog.value && !showPurchaseDialog.value &&
!showReturnDialog.value, !showReturnDialog.value,
) );
// Re-fetch the user's tickets when the purchase dialog closes (the // Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically // buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension. // via the relay republish from the events extension.
watch(showPurchaseDialog, (open) => { watch(showPurchaseDialog, (open) => {
if (!open) refreshOwnedTickets() if (!open) refreshOwnedTickets();
}) });
function goToMyTickets() { function goToMyTickets() {
router.push('/my-tickets') router.push("/my-tickets");
} }
</script> </script>
<template> <template>
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }"> <div
class="container mx-auto py-6 px-4 max-w-3xl"
:class="{ 'pb-24': showStickyCta }"
>
<!-- Top bar — back-link only. Edit moves into the title row as a <!-- Top bar — back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. --> where it replaces the Buy-ticket CTA for the host. -->
@ -264,7 +317,9 @@ function goToMyTickets() {
<!-- Error --> <!-- Error -->
<div v-else-if="error" class="text-center py-16"> <div v-else-if="error" class="text-center py-16">
<h2 class="text-xl font-semibold text-foreground mb-2">Event not found</h2> <h2 class="text-xl font-semibold text-foreground mb-2">
Event not found
</h2>
<p class="text-muted-foreground mb-4">{{ error }}</p> <p class="text-muted-foreground mb-4">{{ error }}</p>
<Button variant="outline" @click="reload">Retry</Button> <Button variant="outline" @click="reload">Retry</Button>
</div> </div>
@ -286,11 +341,7 @@ function goToMyTickets() {
<!-- "Yours" leads the row in the highlighted variant so the <!-- "Yours" leads the row in the highlighted variant so the
ownership signal stands out against the neutral ownership signal stands out against the neutral
category/tag chips that follow. --> category/tag chips that follow. -->
<Badge <Badge v-if="event.isMine" variant="secondary" class="shrink-0">
v-if="event.isMine"
variant="secondary"
class="shrink-0"
>
Yours Yours
</Badge> </Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0"> <Badge v-if="categoryLabel" variant="outline" class="shrink-0">
@ -298,10 +349,14 @@ function goToMyTickets() {
</Badge> </Badge>
<Badge <Badge
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'" v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'" :variant="
event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'
"
class="shrink-0 capitalize" class="shrink-0 capitalize"
> >
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{
event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review"
}}
</Badge> </Badge>
<div v-for="tag in event.tags.slice(1)" :key="tag"> <div v-for="tag in event.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge> <Badge variant="outline" class="text-xs">{{ tag }}</Badge>
@ -342,7 +397,9 @@ function goToMyTickets() {
<Calendar class="w-4 h-4 shrink-0 mt-0.5" /> <Calendar class="w-4 h-4 shrink-0 mt-0.5" />
<span> <span>
{{ dateDisplay }} {{ dateDisplay }}
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span> <span v-if="event.timezone" class="opacity-70"
>({{ event.timezone }})</span
>
</span> </span>
</div> </div>
<div v-if="event.location" class="flex items-start gap-1.5"> <div v-if="event.location" class="flex items-start gap-1.5">
@ -364,7 +421,7 @@ function goToMyTickets() {
@click="openScannerPage" @click="openScannerPage"
> >
<ScanLine class="w-4 h-4" /> <ScanLine class="w-4 h-4" />
{{ t('events.detail.scanTickets', 'Scan tickets') }} {{ t("events.detail.scanTickets", "Scan tickets") }}
</Button> </Button>
<!-- Tickets — gated on the event carrying ticketInfo (set <!-- Tickets — gated on the event carrying ticketInfo (set
@ -384,13 +441,21 @@ function goToMyTickets() {
v-if="ownedPaidCount > 0" v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3" class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
> >
<div class="flex items-center gap-2 text-sm font-medium text-foreground"> <div
class="flex items-center gap-2 text-sm font-medium text-foreground"
>
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" /> <CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }} {{
t(
"events.detail.ticketsOwned",
{ count: ownedPaidCount },
ownedPaidCount,
)
}}
</div> </div>
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets"> <Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }} {{ t("events.detail.viewMyTickets", "View in My Tickets") }}
</Button> </Button>
</div> </div>
@ -399,7 +464,7 @@ function goToMyTickets() {
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3" class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
> >
<History class="w-4 h-4 shrink-0" /> <History class="w-4 h-4 shrink-0" />
{{ t('events.detail.pastEvent', 'This event has already happened') }} {{ t("events.detail.pastEvent", "This event has already happened") }}
</div> </div>
<div v-else-if="canBuyTicket" class="space-y-1"> <div v-else-if="canBuyTicket" class="space-y-1">
<Button <Button
@ -414,10 +479,14 @@ function goToMyTickets() {
</Button> </Button>
<p class="text-xs text-muted-foreground text-center"> <p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
</span> </span>
<span v-else> <span v-else>
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }} {{
t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available,
})
}}
</span> </span>
</p> </p>
</div> </div>
@ -425,7 +494,7 @@ function goToMyTickets() {
v-else-if="ownedPaidCount === 0" v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center" class="text-sm text-destructive text-center"
> >
{{ t('events.detail.soldOut') }} {{ t("events.detail.soldOut") }}
</p> </p>
</div> </div>
@ -458,50 +527,68 @@ function goToMyTickets() {
parent's space-y margin (margins offset fixed boxes too) and parent's space-y margin (margins offset fixed boxes too) and
float 24px above the nav. --> float 24px above the nav. -->
<Teleport to="body"> <Teleport to="body">
<Transition <Transition
enter-active-class="transition duration-200 ease-out" enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0" enter-from-class="translate-y-4 opacity-0"
leave-active-class="transition duration-150 ease-in" leave-active-class="transition duration-150 ease-in"
leave-to-class="translate-y-4 opacity-0" leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="showStickyCta && event"
class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style="bottom: var(--bottom-nav-height)"
role="region"
:aria-label="t('events.detail.tickets')"
> >
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3"> <div
<div class="min-w-0 flex-1"> v-if="showStickyCta && event"
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p> class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
<p class="text-xs text-muted-foreground truncate"> style="bottom: var(--bottom-nav-height)"
<span v-if="ownedPaidCount > 0"> role="region"
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }} :aria-label="t('events.detail.tickets')"
</span> >
<span v-else-if="event.ticketInfo?.available === undefined"> <div
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3"
</span>
<span v-else>
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span>
</p>
</div>
<Button
v-if="canBuyTicket"
class="gap-1.5 shrink-0"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
> >
<Ticket class="w-4 h-4" /> <div class="min-w-0 flex-1">
{{ buyLabel }} <p class="text-sm font-medium text-foreground truncate">
</Button> {{ event.title }}
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets"> </p>
<Ticket class="w-4 h-4" /> <p class="text-xs text-muted-foreground truncate">
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }} <span v-if="ownedPaidCount > 0">
</Button> {{
t(
"events.detail.ticketsOwned",
{ count: ownedPaidCount },
ownedPaidCount,
)
}}
</span>
<span v-else-if="event.ticketInfo?.available === undefined">
{{ priceLabel }} ·
{{
t("events.detail.unlimitedTickets", "Unlimited tickets")
}}
</span>
<span v-else>
{{ priceLabel }} ·
{{
t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available,
})
}}
</span>
</p>
</div>
<Button
v-if="canBuyTicket"
class="gap-1.5 shrink-0"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ buyLabel }}
</Button>
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t("events.detail.viewMyTickets", "View in My Tickets") }}
</Button>
</div>
</div> </div>
</div> </Transition>
</Transition>
</Teleport> </Teleport>
<!-- External references --> <!-- External references -->
@ -513,8 +600,10 @@ function goToMyTickets() {
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p
{{ t('events.detail.organizer') }} class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"
>
{{ t("events.detail.organizer") }}
</p> </p>
<OrganizerCard :pubkey="event.organizer.pubkey" /> <OrganizerCard :pubkey="event.organizer.pubkey" />
</div> </div>