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 = {
nav: {
home: 'Home',
title: 'Virtual Realm',
directory: 'Directory',
faq: 'FAQ',
events: 'Events',
market: 'Market',
chat: 'Chat',
login: 'Login',
logout: 'Logout'
home: "Home",
title: "Virtual Realm",
directory: "Directory",
faq: "FAQ",
events: "Events",
market: "Market",
chat: "Chat",
login: "Login",
logout: "Logout",
},
common: {
loading: 'Loading...',
error: 'An error occurred',
success: 'Operation successful',
loading: "Loading...",
error: "An error occurred",
success: "Operation successful",
nav: {
profile: 'Profile',
preferences: 'Preferences',
profileDescription: 'Your Nostr identity and display name.',
profileLoggedOutDescription: 'Sign in or change your preferences.',
login: 'Log in',
menu: 'Menu',
backToHub: 'Back to hub',
hub: 'Hub',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
colorScheme: 'Color scheme',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Countryside Castle',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Emerald Forest',
palette_lightgreen: 'Light Green',
palette_neobrut: 'Neo Brutalist',
palette_starrynight: 'Starry Night',
language: 'Language',
currency: 'Currency',
currencyComingSoon: 'Currency picker — coming soon',
currencyComingSoonDescription: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.'
}
profile: "Profile",
preferences: "Preferences",
profileDescription: "Your Nostr identity and display name.",
profileLoggedOutDescription: "Sign in or change your preferences.",
login: "Log in",
menu: "Menu",
backToHub: "Back to hub",
hub: "Hub",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
themeSystem: "System",
colorScheme: "Color scheme",
palette_catppuccin: "Catppuccin",
palette_countrysidecastle: "Countryside Castle",
palette_darkmatter: "Dark Matter",
palette_emeraldforest: "Emerald Forest",
palette_lightgreen: "Light Green",
palette_neobrut: "Neo Brutalist",
palette_starrynight: "Starry Night",
language: "Language",
currency: "Currency",
currencyComingSoon: "Currency picker — coming soon",
currencyComingSoonDescription:
"A preferred-currency setting (sats/USD/EUR) is on the roadmap.",
},
},
errors: {
notFound: 'Page not found',
serverError: 'Server error occurred',
networkError: 'Network connection error'
notFound: "Page not found",
serverError: "Server error occurred",
networkError: "Network connection error",
},
locales: {
en: 'English',
es: 'Spanish',
fr: 'French',
de: 'German',
zh: 'Chinese'
en: "English",
es: "Spanish",
fr: "French",
de: "German",
zh: "Chinese",
},
events: {
title: 'Events',
createNew: 'Create Event',
title: "Events",
createNew: "Create Event",
create: {
promo: {
title: 'Promo codes',
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.',
empty: 'No promo codes yet.',
code: 'Code',
codePlaceholder: 'EARLYBIRD',
discount: 'Discount %',
maxUses: 'Max uses',
active: 'Active',
used: 'Used {count}',
add: 'Add code',
removeLabel: 'Remove code {code}',
title: "Promo codes",
hint: "Percentage discounts buyers can enter at checkout. Codes are never shown publicly.",
empty: "No promo codes yet.",
code: "Code",
codePlaceholder: "EARLYBIRD",
discount: "Discount %",
maxUses: "Max uses",
active: "Active",
used: "Used {count}",
add: "Add code",
removeLabel: "Remove code {code}",
errors: {
required: 'Enter a code',
duplicate: 'Duplicate code',
percentRange: 'Between 1 and 100',
maxUsesMin: 'Must be 1 or more',
required: "Enter a code",
duplicate: "Duplicate code",
percentRange: "Between 1 and 100",
maxUsesMin: "Must be 1 or more",
},
},
},
noEvents: 'No events found',
noEvents: "No events found",
filters: {
all: 'All',
today: 'Today',
tomorrow: 'Tomorrow',
thisWeek: 'This Week',
thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
filters: 'Filters',
clearAll: 'Clear all',
filteringBy: 'Filtering by:',
removeCategory: 'Remove {category} filter',
all: "All",
today: "Today",
tomorrow: "Tomorrow",
thisWeek: "This Week",
thisMonth: "This Month",
myTickets: "My tickets",
hosting: "Hosting",
pastEvents: "Past events",
past: "Past",
filters: "Filters",
clearAll: "Clear all",
filteringBy: "Filtering by:",
removeCategory: "Remove {category} filter",
},
categories: {
concert: 'Concert',
workshop: 'Workshop',
market: 'Market',
festival: 'Festival',
exhibition: 'Exhibition',
sport: 'Sport',
theater: 'Theater',
cinema: 'Cinema',
party: 'Party',
talk: 'Talk',
conference: 'Conference',
meetup: 'Meetup',
food: 'Food',
outdoor: 'Outdoor',
kids: 'Kids',
wellness: 'Wellness',
technology: 'Technology',
art: 'Art',
music: 'Music',
dance: 'Dance',
literature: 'Literature',
comedy: 'Comedy',
charity: 'Charity',
tradition: 'Tradition',
other: 'Other',
concert: "Concert",
workshop: "Workshop",
market: "Market",
festival: "Festival",
exhibition: "Exhibition",
sport: "Sport",
theater: "Theater",
cinema: "Cinema",
party: "Party",
talk: "Talk",
conference: "Conference",
meetup: "Meetup",
food: "Food",
outdoor: "Outdoor",
kids: "Kids",
wellness: "Wellness",
technology: "Technology",
art: "Art",
music: "Music",
dance: "Dance",
literature: "Literature",
comedy: "Comedy",
charity: "Charity",
tradition: "Tradition",
other: "Other",
},
detail: {
getTicket: 'Get Ticket',
contactOrganizer: 'Contact Organizer',
organizer: 'Organizer',
location: 'Location',
when: 'When',
tickets: 'Tickets',
ticketsAvailable: '{count} tickets available',
ticketsRemainingOfTotal: '{count} of {total} tickets left',
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket',
buyAnotherTicket: 'Buy another ticket',
viewMyTickets: 'View in My Tickets',
soldOut: 'Sold Out',
pastEvent: 'This event has already happened',
loginToBuyTickets: 'Log in to buy tickets',
logIn: 'Log in',
free: 'Free',
getTicket: "Get Ticket",
contactOrganizer: "Contact Organizer",
organizer: "Organizer",
location: "Location",
when: "When",
tickets: "Tickets",
ticketsAvailable: "{count} tickets available",
ticketsOwned: "You have {count} ticket | You have {count} tickets",
unlimitedTickets: "Unlimited tickets",
buyTicket: "Buy ticket",
buyAnotherTicket: "Buy another ticket",
viewMyTickets: "View in My Tickets",
soldOut: "Sold Out",
pastEvent: "This event has already happened",
loginToBuyTickets: "Log in to buy tickets",
logIn: "Log in",
free: "Free",
},
tickets: {
myTickets: 'My Tickets',
scanTicket: 'Scan Ticket',
noTickets: 'No tickets yet',
paid: 'Paid',
pending: 'Pending',
registered: 'Registered',
myTickets: "My Tickets",
scanTicket: "Scan Ticket",
noTickets: "No tickets yet",
paid: "Paid",
pending: "Pending",
registered: "Registered",
},
purchase: {
card: 'Card',
continueTo: 'Continue to {provider} checkout',
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.",
noCheckoutUrl: 'Fiat provider did not return a checkout URL.',
fiatFailed: 'Card checkout failed',
loginRequiredTitle: 'Login required',
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
card: "Card",
continueTo: "Continue to {provider} checkout",
fiatRedirectHint:
"You'll be taken to the provider's secure checkout and brought back here once paid.",
noCheckoutUrl: "Fiat provider did not return a checkout URL.",
fiatFailed: "Card checkout failed",
loginRequiredTitle: "Login required",
loginRequiredFree:
"Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.",
promo: {
label: 'Promo code',
placeholder: 'Enter code',
apply: 'Apply',
remove: 'Remove',
checking: 'Checking…',
applied: '{code} −{percent}%',
discountLine: 'Discount ({code})',
invalid: 'This code is not valid for this purchase.',
unverified: 'Could not verify the code — it will be applied at checkout if valid.',
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.',
label: "Promo code",
placeholder: "Enter code",
apply: "Apply",
remove: "Remove",
checking: "Checking…",
applied: "{code} −{percent}%",
discountLine: "Discount ({code})",
invalid: "This code is not valid for this purchase.",
unverified:
"Could not verify the code — it will be applied at checkout if valid.",
loginRequiredDiscount:
"This code makes your ticket free. Free tickets need an account — log in to claim it.",
},
contact: {
title: 'Your details',
name: 'Name',
namePlaceholder: 'Name on the ticket',
nameRequired: 'Name is required',
email: 'Email',
emailHint: 'Your ticket will be sent to this address.',
spamHint: 'If it has not arrived a few minutes after paying, check your spam folder.',
emailInvalid: 'Enter a valid email address',
confirmEmail: 'Confirm email',
emailMismatch: 'Email addresses do not match',
title: "Your details",
name: "Name",
namePlaceholder: "Name on the ticket",
nameRequired: "Name is required",
email: "Email",
emailHint: "Your ticket will be sent to this address.",
spamHint:
"If it has not arrived a few minutes after paying, check your spam folder.",
emailInvalid: "Enter a valid email address",
confirmEmail: "Confirm email",
emailMismatch: "Email addresses do not match",
},
},
checkout: {
title: 'Confirming your payment…',
paidTitle: 'Payment received',
processing: 'Waiting for the payment to settle. This usually takes a few seconds.',
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.',
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.',
emailedTo: 'We emailed your ticket to {email}.',
cancelled: 'Payment cancelled — no seats were reserved.',
viewTicket: 'Open ticket page',
viewMyTickets: 'View My Tickets',
retry: 'Check again',
ticketN: 'Ticket {n} of {total}',
close: 'Close',
title: "Confirming your payment…",
paidTitle: "Payment received",
processing:
"Waiting for the payment to settle. This usually takes a few seconds.",
stillProcessing:
"Still processing. Your ticket will be emailed to {email} as soon as the payment settles.",
stillProcessingNoEmail:
"Still processing. Reload this page in a moment to check again.",
emailedTo: "We emailed your ticket to {email}.",
cancelled: "Payment cancelled — no seats were reserved.",
viewTicket: "Open ticket page",
viewMyTickets: "View My Tickets",
retry: "Check again",
ticketN: "Ticket {n} of {total}",
close: "Close",
},
ticket: {
title: 'Your ticket',
loading: 'Loading ticket…',
notFound: 'Ticket not found',
event: 'Event',
holder: 'Name',
paid: 'Paid',
pending: 'Payment pending',
registered: 'Checked in',
showAtDoor: 'Show this QR at the door.',
backToEvent: 'Back to event',
title: "Your ticket",
loading: "Loading ticket…",
notFound: "Ticket not found",
event: "Event",
holder: "Name",
paid: "Paid",
pending: "Payment pending",
registered: "Checked in",
showAtDoor: "Show this QR at the door.",
backToEvent: "Back to event",
},
nav: {
feed: 'Home',
calendar: 'Calendar',
map: 'Map',
favorites: 'Favorites',
settings: 'Settings',
feed: "Home",
calendar: "Calendar",
map: "Map",
favorites: "Favorites",
settings: "Settings",
},
search: {
placeholder: 'Search events...',
noResults: 'No events found',
placeholder: "Search events...",
noResults: "No events found",
},
favorites: {
title: 'Favorites',
loginPrompt: 'Log in to save your favorite events',
empty: 'No favorites yet',
emptyHint: 'Tap the heart icon on any event to save it here',
logIn: 'Log in',
title: "Favorites",
loginPrompt: "Log in to save your favorite events",
empty: "No favorites yet",
emptyHint: "Tap the heart icon on any event to save it here",
logIn: "Log in",
},
settings: {
title: 'Settings',
account: 'Account',
loginPrompt: 'Log in to bookmark events, RSVP, and purchase tickets.',
logIn: 'Log in',
logOut: 'Log out',
appearance: 'Appearance',
theme: 'Theme',
language: 'Language',
title: "Settings",
account: "Account",
loginPrompt: "Log in to bookmark events, RSVP, and purchase tickets.",
logIn: "Log in",
logOut: "Log out",
appearance: "Appearance",
theme: "Theme",
language: "Language",
},
},
libra: {
nav: {
record: 'Record',
transactions: 'Transactions',
balance: 'Balance',
wallet: 'Wallet',
settings: 'Settings',
record: "Record",
transactions: "Transactions",
balance: "Balance",
wallet: "Wallet",
settings: "Settings",
},
record: {
title: 'Record',
addExpense: 'Add Expense',
addExpenseDescription: 'Submit an expense for approval',
addIncome: 'Add Income',
addIncomeDescription: 'Record income or revenue received',
comingSoon: 'Coming Soon',
drafts: 'Drafts',
noDrafts: 'No saved drafts',
draftAge: 'Saved {time}',
saveDraft: 'Save Draft',
deleteDraft: 'Delete Draft',
title: "Record",
addExpense: "Add Expense",
addExpenseDescription: "Submit an expense for approval",
addIncome: "Add Income",
addIncomeDescription: "Record income or revenue received",
comingSoon: "Coming Soon",
drafts: "Drafts",
noDrafts: "No saved drafts",
draftAge: "Saved {time}",
saveDraft: "Save Draft",
deleteDraft: "Delete Draft",
},
balance: {
title: 'Balance',
netBalance: 'Net Balance',
pending: 'Pending',
pendingExpenses: '{count} pending expense | {count} pending expenses',
pendingAmount: '{amount} pending approval',
noBalance: 'No balance data available',
owedToYou: 'Owed to you',
youOwe: 'You owe',
title: "Balance",
netBalance: "Net Balance",
pending: "Pending",
pendingExpenses: "{count} pending expense | {count} pending expenses",
pendingAmount: "{amount} pending approval",
noBalance: "No balance data available",
owedToYou: "Owed to you",
youOwe: "You owe",
},
settings: {
title: 'Settings',
account: 'Account',
loginPrompt: 'Log in to record expenses and view your balance.',
logIn: 'Log in',
logOut: 'Log out',
appearance: 'Appearance',
theme: 'Theme',
language: 'Language',
title: "Settings",
account: "Account",
loginPrompt: "Log in to record expenses and view your balance.",
logIn: "Log in",
logOut: "Log out",
appearance: "Appearance",
theme: "Theme",
language: "Language",
},
income: {
title: 'Add Income',
description: 'Submit income for the organization',
selectAccount: 'Select the revenue account.',
title: "Add Income",
description: "Submit income for the organization",
selectAccount: "Select the revenue account.",
otherAccountHint: 'Use the "Other" account if you\'re not sure.',
submitIncome: 'Submit Income',
notAvailable: 'Income submission is not yet available. This feature is coming soon.',
submitIncome: "Submit Income",
notAvailable:
"Income submission is not yet available. This feature is coming soon.",
},
},
market: {
auth: {
loginPrompt: 'Log in to place your order',
logIn: 'Log in',
logInToCheckout: 'Log in to checkout',
nostrKeyRequired: 'A Nostr identity is required',
nostrKeyDescription: 'Configure your Nostr public key in Profile settings to place orders.',
loginPrompt: "Log in to place your order",
logIn: "Log in",
logInToCheckout: "Log in to checkout",
nostrKeyRequired: "A Nostr identity is required",
nostrKeyDescription:
"Configure your Nostr public key in Profile settings to place orders.",
},
},
dateTimeFormats: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
year: "numeric",
month: "short",
day: "numeric",
},
long: {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: 'numeric'
}
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
hour: "numeric",
minute: "numeric",
},
},
numberFormats: {
currency: {
style: 'currency',
currencyDisplay: 'symbol'
style: "currency",
currencyDisplay: "symbol",
},
decimal: {
style: 'decimal',
minimumFractionDigits: 2
style: "decimal",
minimumFractionDigits: 2,
},
percent: {
style: 'percent',
useGrouping: false
}
}
}
style: "percent",
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 = {
nav: {
title: 'Reino Virtual',
home: 'Inicio',
directory: 'Directorio',
faq: 'Preguntas Frecuentes',
events: 'Eventos',
market: 'Mercado',
chat: 'Chat',
login: 'Iniciar Sesión',
logout: 'Cerrar Sesión'
title: "Reino Virtual",
home: "Inicio",
directory: "Directorio",
faq: "Preguntas Frecuentes",
events: "Eventos",
market: "Mercado",
chat: "Chat",
login: "Iniciar Sesión",
logout: "Cerrar Sesión",
},
common: {
loading: 'Cargando...',
error: 'Ha ocurrido un error',
success: 'Operación exitosa',
loading: "Cargando...",
error: "Ha ocurrido un error",
success: "Operación exitosa",
nav: {
profile: 'Perfil',
preferences: 'Preferencias',
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
login: 'Iniciar sesión',
menu: 'Menú',
backToHub: 'Volver al hub',
hub: 'Hub',
theme: 'Tema',
themeLight: 'Claro',
themeDark: 'Oscuro',
themeSystem: 'Sistema',
colorScheme: 'Paleta',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Castillo Campestre',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Bosque Esmeralda',
palette_lightgreen: 'Verde Claro',
palette_neobrut: 'Neo Brutalista',
palette_starrynight: 'Noche Estrellada',
language: 'Idioma',
currency: 'Moneda',
currencyComingSoon: 'Selector de moneda — próximamente',
currencyComingSoonDescription: 'Un ajuste de moneda preferida (sats/USD/EUR) está en la hoja de ruta.'
}
profile: "Perfil",
preferences: "Preferencias",
profileDescription: "Tu identidad Nostr y nombre de visualización.",
profileLoggedOutDescription: "Inicia sesión o cambia tus preferencias.",
login: "Iniciar sesión",
menu: "Menú",
backToHub: "Volver al hub",
hub: "Hub",
theme: "Tema",
themeLight: "Claro",
themeDark: "Oscuro",
themeSystem: "Sistema",
colorScheme: "Paleta",
palette_catppuccin: "Catppuccin",
palette_countrysidecastle: "Castillo Campestre",
palette_darkmatter: "Dark Matter",
palette_emeraldforest: "Bosque Esmeralda",
palette_lightgreen: "Verde Claro",
palette_neobrut: "Neo Brutalista",
palette_starrynight: "Noche Estrellada",
language: "Idioma",
currency: "Moneda",
currencyComingSoon: "Selector de moneda — próximamente",
currencyComingSoonDescription:
"Un ajuste de moneda preferida (sats/USD/EUR) está en la hoja de ruta.",
},
},
errors: {
notFound: 'Página no encontrada',
serverError: 'Error del servidor',
networkError: 'Error de conexión de red'
notFound: "Página no encontrada",
serverError: "Error del servidor",
networkError: "Error de conexión de red",
},
locales: {
en: 'Inglés',
es: 'Español',
fr: 'Francés',
de: 'Alemán',
zh: 'Chino'
en: "Inglés",
es: "Español",
fr: "Francés",
de: "Alemán",
zh: "Chino",
},
events: {
title: 'Eventos',
createNew: 'Crear evento',
title: "Eventos",
createNew: "Crear evento",
create: {
promo: {
title: 'Códigos promocionales',
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.',
empty: 'Aún no hay códigos promocionales.',
code: 'Código',
codePlaceholder: 'EARLYBIRD',
discount: 'Descuento %',
maxUses: 'Usos máx.',
active: 'Activo',
used: 'Usado {count}',
add: 'Añadir código',
removeLabel: 'Eliminar el código {code}',
title: "Códigos promocionales",
hint: "Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.",
empty: "Aún no hay códigos promocionales.",
code: "Código",
codePlaceholder: "EARLYBIRD",
discount: "Descuento %",
maxUses: "Usos máx.",
active: "Activo",
used: "Usado {count}",
add: "Añadir código",
removeLabel: "Eliminar el código {code}",
errors: {
required: 'Introduce un código',
duplicate: 'Código duplicado',
percentRange: 'Entre 1 y 100',
maxUsesMin: 'Debe ser 1 o más',
required: "Introduce un código",
duplicate: "Código duplicado",
percentRange: "Entre 1 y 100",
maxUsesMin: "Debe ser 1 o más",
},
},
},
noEvents: 'No se encontraron eventos',
noEvents: "No se encontraron eventos",
filters: {
all: 'Todas',
today: 'Hoy',
tomorrow: 'Mañana',
thisWeek: 'Esta semana',
thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
filters: 'Filtros',
clearAll: 'Limpiar todo',
filteringBy: 'Filtrando por:',
removeCategory: 'Quitar el filtro {category}',
all: "Todas",
today: "Hoy",
tomorrow: "Mañana",
thisWeek: "Esta semana",
thisMonth: "Este mes",
myTickets: "Mis boletos",
hosting: "Organizo",
pastEvents: "Eventos pasados",
past: "Pasado",
filters: "Filtros",
clearAll: "Limpiar todo",
filteringBy: "Filtrando por:",
removeCategory: "Quitar el filtro {category}",
},
categories: {
concert: 'Concierto',
workshop: 'Taller',
market: 'Mercado',
festival: 'Festival',
exhibition: 'Exposición',
sport: 'Deporte',
theater: 'Teatro',
cinema: 'Cine',
party: 'Fiesta',
talk: 'Charla',
conference: 'Conferencia',
meetup: 'Encuentro',
food: 'Gastronomía',
outdoor: 'Al aire libre',
kids: 'Niños',
wellness: 'Bienestar',
technology: 'Tecnología',
art: 'Arte',
music: 'Música',
dance: 'Danza',
literature: 'Literatura',
comedy: 'Comedia',
charity: 'Solidario',
tradition: 'Tradición',
other: 'Otro',
concert: "Concierto",
workshop: "Taller",
market: "Mercado",
festival: "Festival",
exhibition: "Exposición",
sport: "Deporte",
theater: "Teatro",
cinema: "Cine",
party: "Fiesta",
talk: "Charla",
conference: "Conferencia",
meetup: "Encuentro",
food: "Gastronomía",
outdoor: "Al aire libre",
kids: "Niños",
wellness: "Bienestar",
technology: "Tecnología",
art: "Arte",
music: "Música",
dance: "Danza",
literature: "Literatura",
comedy: "Comedia",
charity: "Solidario",
tradition: "Tradición",
other: "Otro",
},
detail: {
getTicket: 'Obtener boleto',
contactOrganizer: 'Contactar organizador',
organizer: 'Organizador',
location: 'Ubicación',
when: 'Cuándo',
tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles',
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto',
buyAnotherTicket: 'Comprar otro boleto',
viewMyTickets: 'Ver en Mis boletos',
soldOut: 'Agotado',
pastEvent: 'Este evento ya pasó',
loginToBuyTickets: 'Inicia sesión para comprar boletos',
logIn: 'Iniciar sesión',
free: 'Gratis',
getTicket: "Obtener boleto",
contactOrganizer: "Contactar organizador",
organizer: "Organizador",
location: "Ubicación",
when: "Cuándo",
tickets: "Boletos",
ticketsAvailable: "{count} boletos disponibles",
ticketsOwned: "Tienes {count} boleto | Tienes {count} boletos",
unlimitedTickets: "Boletos ilimitados",
buyTicket: "Comprar boleto",
buyAnotherTicket: "Comprar otro boleto",
viewMyTickets: "Ver en Mis boletos",
soldOut: "Agotado",
pastEvent: "Este evento ya pasó",
loginToBuyTickets: "Inicia sesión para comprar boletos",
logIn: "Iniciar sesión",
free: "Gratis",
},
tickets: {
myTickets: 'Mis boletos',
scanTicket: 'Escanear boleto',
noTickets: 'Aún no tienes boletos',
paid: 'Pagado',
pending: 'Pendiente',
registered: 'Registrado',
myTickets: "Mis boletos",
scanTicket: "Escanear boleto",
noTickets: "Aún no tienes boletos",
paid: "Pagado",
pending: "Pendiente",
registered: "Registrado",
},
purchase: {
card: 'Tarjeta',
continueTo: 'Continuar al pago con {provider}',
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.',
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.',
fiatFailed: 'El pago con tarjeta falló',
loginRequiredTitle: 'Inicio de sesión requerido',
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
card: "Tarjeta",
continueTo: "Continuar al pago con {provider}",
fiatRedirectHint:
"Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.",
noCheckoutUrl: "El proveedor no devolvió una URL de pago.",
fiatFailed: "El pago con tarjeta falló",
loginRequiredTitle: "Inicio de sesión requerido",
loginRequiredFree:
"Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.",
promo: {
label: 'Código promocional',
placeholder: 'Introduce el código',
apply: 'Aplicar',
remove: 'Quitar',
checking: 'Comprobando…',
applied: '{code} −{percent}%',
discountLine: 'Descuento ({code})',
invalid: 'Este código no es válido para esta compra.',
unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.',
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.',
label: "Código promocional",
placeholder: "Introduce el código",
apply: "Aplicar",
remove: "Quitar",
checking: "Comprobando…",
applied: "{code} −{percent}%",
discountLine: "Descuento ({code})",
invalid: "Este código no es válido para esta compra.",
unverified:
"No se pudo verificar el código; se aplicará al pagar si es válido.",
loginRequiredDiscount:
"Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.",
},
contact: {
title: 'Tus datos',
name: 'Nombre',
namePlaceholder: 'Nombre en el boleto',
nameRequired: 'El nombre es obligatorio',
email: 'Correo electrónico',
emailHint: 'Tu boleto se enviará a esta dirección.',
spamHint: 'Si no llega unos minutos después de pagar, revisa tu carpeta de spam.',
emailInvalid: 'Introduce un correo electrónico válido',
confirmEmail: 'Confirmar correo',
emailMismatch: 'Los correos no coinciden',
title: "Tus datos",
name: "Nombre",
namePlaceholder: "Nombre en el boleto",
nameRequired: "El nombre es obligatorio",
email: "Correo electrónico",
emailHint: "Tu boleto se enviará a esta dirección.",
spamHint:
"Si no llega unos minutos después de pagar, revisa tu carpeta de spam.",
emailInvalid: "Introduce un correo electrónico válido",
confirmEmail: "Confirmar correo",
emailMismatch: "Los correos no coinciden",
},
},
checkout: {
title: 'Confirmando tu pago…',
paidTitle: 'Pago recibido',
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.',
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.',
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.',
emailedTo: 'Enviamos tu boleto a {email}.',
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
viewTicket: 'Abrir página del boleto',
viewMyTickets: 'Ver mis boletos',
retry: 'Comprobar de nuevo',
ticketN: 'Boleto {n} de {total}',
close: 'Cerrar',
title: "Confirmando tu pago…",
paidTitle: "Pago recibido",
processing:
"Esperando a que se liquide el pago. Suele tardar unos segundos.",
stillProcessing:
"Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.",
stillProcessingNoEmail:
"Aún en proceso. Recarga esta página en un momento.",
emailedTo: "Enviamos tu boleto a {email}.",
cancelled: "Pago cancelado — no se reservó ningún asiento.",
viewTicket: "Abrir página del boleto",
viewMyTickets: "Ver mis boletos",
retry: "Comprobar de nuevo",
ticketN: "Boleto {n} de {total}",
close: "Cerrar",
},
ticket: {
title: 'Tu boleto',
loading: 'Cargando boleto…',
notFound: 'Boleto no encontrado',
event: 'Evento',
holder: 'Nombre',
paid: 'Pagado',
pending: 'Pago pendiente',
registered: 'Registrado',
showAtDoor: 'Muestra este QR en la entrada.',
backToEvent: 'Volver al evento',
title: "Tu boleto",
loading: "Cargando boleto…",
notFound: "Boleto no encontrado",
event: "Evento",
holder: "Nombre",
paid: "Pagado",
pending: "Pago pendiente",
registered: "Registrado",
showAtDoor: "Muestra este QR en la entrada.",
backToEvent: "Volver al evento",
},
nav: {
feed: 'Inicio',
calendar: 'Calendario',
map: 'Mapa',
favorites: 'Favoritos',
settings: 'Ajustes',
feed: "Inicio",
calendar: "Calendario",
map: "Mapa",
favorites: "Favoritos",
settings: "Ajustes",
},
search: {
placeholder: 'Buscar eventos...',
noResults: 'No se encontraron eventos',
placeholder: "Buscar eventos...",
noResults: "No se encontraron eventos",
},
favorites: {
title: 'Favoritos',
loginPrompt: 'Inicia sesión para guardar tus eventos favoritos',
empty: 'Aún no tienes favoritos',
emptyHint: 'Toca el corazón en cualquier evento para guardarlo aquí',
logIn: 'Iniciar sesión',
title: "Favoritos",
loginPrompt: "Inicia sesión para guardar tus eventos favoritos",
empty: "Aún no tienes favoritos",
emptyHint: "Toca el corazón en cualquier evento para guardarlo aquí",
logIn: "Iniciar sesión",
},
settings: {
title: 'Ajustes',
account: 'Cuenta',
loginPrompt: 'Inicia sesión para guardar eventos, confirmar asistencia y comprar boletos.',
logIn: 'Iniciar sesión',
logOut: 'Cerrar sesión',
appearance: 'Apariencia',
theme: 'Tema',
language: 'Idioma',
title: "Ajustes",
account: "Cuenta",
loginPrompt:
"Inicia sesión para guardar eventos, confirmar asistencia y comprar boletos.",
logIn: "Iniciar sesión",
logOut: "Cerrar sesión",
appearance: "Apariencia",
theme: "Tema",
language: "Idioma",
},
},
libra: {
nav: {
record: 'Registrar',
transactions: 'Transacciones',
balance: 'Saldo',
wallet: 'Billetera',
settings: 'Ajustes',
record: "Registrar",
transactions: "Transacciones",
balance: "Saldo",
wallet: "Billetera",
settings: "Ajustes",
},
record: {
title: 'Registrar',
addExpense: 'A\u00f1adir gasto',
addExpenseDescription: 'Enviar un gasto para aprobaci\u00f3n',
addIncome: 'A\u00f1adir ingreso',
addIncomeDescription: 'Registrar un ingreso recibido',
comingSoon: 'Pr\u00f3ximamente',
drafts: 'Borradores',
noDrafts: 'Sin borradores',
draftAge: 'Guardado {time}',
saveDraft: 'Guardar borrador',
deleteDraft: 'Eliminar borrador',
title: "Registrar",
addExpense: "A\u00f1adir gasto",
addExpenseDescription: "Enviar un gasto para aprobaci\u00f3n",
addIncome: "A\u00f1adir ingreso",
addIncomeDescription: "Registrar un ingreso recibido",
comingSoon: "Pr\u00f3ximamente",
drafts: "Borradores",
noDrafts: "Sin borradores",
draftAge: "Guardado {time}",
saveDraft: "Guardar borrador",
deleteDraft: "Eliminar borrador",
},
balance: {
title: 'Saldo',
netBalance: 'Saldo neto',
pending: 'Pendiente',
pendingExpenses: '{count} gasto pendiente | {count} gastos pendientes',
pendingAmount: '{amount} pendiente de aprobaci\u00f3n',
noBalance: 'No hay datos de saldo disponibles',
owedToYou: 'Te deben',
youOwe: 'Debes',
title: "Saldo",
netBalance: "Saldo neto",
pending: "Pendiente",
pendingExpenses: "{count} gasto pendiente | {count} gastos pendientes",
pendingAmount: "{amount} pendiente de aprobaci\u00f3n",
noBalance: "No hay datos de saldo disponibles",
owedToYou: "Te deben",
youOwe: "Debes",
},
settings: {
title: 'Ajustes',
account: 'Cuenta',
loginPrompt: 'Inicia sesi\u00f3n para registrar gastos y ver tu saldo.',
logIn: 'Iniciar sesi\u00f3n',
logOut: 'Cerrar sesi\u00f3n',
appearance: 'Apariencia',
theme: 'Tema',
language: 'Idioma',
title: "Ajustes",
account: "Cuenta",
loginPrompt: "Inicia sesi\u00f3n para registrar gastos y ver tu saldo.",
logIn: "Iniciar sesi\u00f3n",
logOut: "Cerrar sesi\u00f3n",
appearance: "Apariencia",
theme: "Tema",
language: "Idioma",
},
income: {
title: 'A\u00f1adir ingreso',
description: 'Enviar un ingreso para la organizaci\u00f3n',
selectAccount: 'Seleccionar la cuenta de ingresos.',
title: "A\u00f1adir ingreso",
description: "Enviar un ingreso para la organizaci\u00f3n",
selectAccount: "Seleccionar la cuenta de ingresos.",
otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
submitIncome: 'Enviar ingreso',
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
submitIncome: "Enviar ingreso",
notAvailable:
"El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.",
},
},
market: {
auth: {
loginPrompt: 'Inicia sesi\u00f3n para realizar tu pedido',
logIn: 'Iniciar sesi\u00f3n',
logInToCheckout: 'Iniciar sesi\u00f3n para finalizar compra',
nostrKeyRequired: 'Se requiere una identidad Nostr',
nostrKeyDescription: 'Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.',
loginPrompt: "Inicia sesi\u00f3n para realizar tu pedido",
logIn: "Iniciar sesi\u00f3n",
logInToCheckout: "Iniciar sesi\u00f3n para finalizar compra",
nostrKeyRequired: "Se requiere una identidad Nostr",
nostrKeyDescription:
"Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.",
},
},
dateTimeFormats: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
year: "numeric",
month: "short",
day: "numeric",
},
long: {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: 'numeric'
}
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
hour: "numeric",
minute: "numeric",
},
},
numberFormats: {
currency: {
style: 'currency',
currencyDisplay: 'symbol'
style: "currency",
currencyDisplay: "symbol",
},
decimal: {
style: 'decimal',
minimumFractionDigits: 2
style: "decimal",
minimumFractionDigits: 2,
},
percent: {
style: 'percent',
useGrouping: false
}
}
}
style: "percent",
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 = {
nav: {
title: 'Monde Virtuel',
home: 'Accueil',
directory: 'Répertoire',
faq: 'FAQ',
events: 'Événements',
market: 'Marché',
chat: 'Chat',
login: 'Connexion',
logout: 'Déconnexion'
title: "Monde Virtuel",
home: "Accueil",
directory: "Répertoire",
faq: "FAQ",
events: "Événements",
market: "Marché",
chat: "Chat",
login: "Connexion",
logout: "Déconnexion",
},
common: {
loading: 'Chargement...',
error: 'Une erreur est survenue',
success: 'Opération réussie',
loading: "Chargement...",
error: "Une erreur est survenue",
success: "Opération réussie",
nav: {
profile: 'Profil',
preferences: 'Préférences',
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
login: 'Se connecter',
menu: 'Menu',
backToHub: 'Retour au hub',
hub: 'Hub',
theme: 'Thème',
themeLight: 'Clair',
themeDark: 'Sombre',
themeSystem: 'Système',
colorScheme: 'Palette',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Château Champêtre',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Forêt d\'Émeraude',
palette_lightgreen: 'Vert Clair',
palette_neobrut: 'Néo-Brutaliste',
palette_starrynight: 'Nuit Étoilée',
language: 'Langue',
currency: 'Devise',
currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
currencyComingSoonDescription: 'Un réglage de devise préférée (sats/USD/EUR) est prévu.'
}
profile: "Profil",
preferences: "Préférences",
profileDescription: "Votre identité Nostr et votre nom d\u2019affichage.",
profileLoggedOutDescription:
"Connectez-vous ou modifiez vos préférences.",
login: "Se connecter",
menu: "Menu",
backToHub: "Retour au hub",
hub: "Hub",
theme: "Thème",
themeLight: "Clair",
themeDark: "Sombre",
themeSystem: "Système",
colorScheme: "Palette",
palette_catppuccin: "Catppuccin",
palette_countrysidecastle: "Château Champêtre",
palette_darkmatter: "Dark Matter",
palette_emeraldforest: "Forêt d'Émeraude",
palette_lightgreen: "Vert Clair",
palette_neobrut: "Néo-Brutaliste",
palette_starrynight: "Nuit Étoilée",
language: "Langue",
currency: "Devise",
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: {
notFound: 'Page non trouvée',
serverError: 'Erreur du serveur',
networkError: 'Erreur de connexion réseau'
notFound: "Page non trouvée",
serverError: "Erreur du serveur",
networkError: "Erreur de connexion réseau",
},
locales: {
en: 'Anglais',
es: 'Espagnol',
fr: 'Français',
de: 'Allemand',
zh: 'Chinois'
en: "Anglais",
es: "Espagnol",
fr: "Français",
de: "Allemand",
zh: "Chinois",
},
events: {
title: 'Événements',
createNew: 'Créer un événement',
title: "Événements",
createNew: "Créer un événement",
create: {
promo: {
title: 'Codes promo',
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.',
empty: 'Aucun code promo pour le moment.',
code: 'Code',
codePlaceholder: 'EARLYBIRD',
discount: 'Remise %',
maxUses: 'Utilisations max.',
active: 'Actif',
used: 'Utilisé {count}',
add: 'Ajouter un code',
removeLabel: 'Supprimer le code {code}',
title: "Codes promo",
hint: "Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.",
empty: "Aucun code promo pour le moment.",
code: "Code",
codePlaceholder: "EARLYBIRD",
discount: "Remise %",
maxUses: "Utilisations max.",
active: "Actif",
used: "Utilisé {count}",
add: "Ajouter un code",
removeLabel: "Supprimer le code {code}",
errors: {
required: 'Saisissez un code',
duplicate: 'Code en double',
percentRange: 'Entre 1 et 100',
maxUsesMin: 'Doit être 1 ou plus',
required: "Saisissez un code",
duplicate: "Code en double",
percentRange: "Entre 1 et 100",
maxUsesMin: "Doit être 1 ou plus",
},
},
},
noEvents: 'Aucun événement trouvé',
noEvents: "Aucun événement trouvé",
filters: {
all: 'Tout',
all: "Tout",
today: "Aujourd'hui",
tomorrow: 'Demain',
thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
filters: 'Filtres',
clearAll: 'Tout effacer',
filteringBy: 'Filtré par :',
removeCategory: 'Retirer le filtre {category}',
tomorrow: "Demain",
thisWeek: "Cette semaine",
thisMonth: "Ce mois-ci",
myTickets: "Mes billets",
hosting: "J'organise",
pastEvents: "Événements passés",
past: "Passé",
filters: "Filtres",
clearAll: "Tout effacer",
filteringBy: "Filtré par :",
removeCategory: "Retirer le filtre {category}",
},
categories: {
concert: 'Concert',
workshop: 'Atelier',
market: 'Marché',
festival: 'Festival',
exhibition: 'Exposition',
sport: 'Sport',
theater: 'Théâtre',
cinema: 'Cinéma',
party: 'Fête',
talk: 'Conférence',
conference: 'Congrès',
meetup: 'Rencontre',
food: 'Gastronomie',
outdoor: 'Plein air',
kids: 'Enfants',
wellness: 'Bien-être',
technology: 'Technologie',
art: 'Art',
music: 'Musique',
dance: 'Danse',
literature: 'Littérature',
comedy: 'Humour',
charity: 'Caritatif',
tradition: 'Tradition',
other: 'Autre',
concert: "Concert",
workshop: "Atelier",
market: "Marché",
festival: "Festival",
exhibition: "Exposition",
sport: "Sport",
theater: "Théâtre",
cinema: "Cinéma",
party: "Fête",
talk: "Conférence",
conference: "Congrès",
meetup: "Rencontre",
food: "Gastronomie",
outdoor: "Plein air",
kids: "Enfants",
wellness: "Bien-être",
technology: "Technologie",
art: "Art",
music: "Musique",
dance: "Danse",
literature: "Littérature",
comedy: "Humour",
charity: "Caritatif",
tradition: "Tradition",
other: "Autre",
},
detail: {
getTicket: 'Obtenir un billet',
getTicket: "Obtenir un billet",
contactOrganizer: "Contacter l'organisateur",
organizer: 'Organisateur',
location: 'Lieu',
when: 'Quand',
tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles',
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet',
buyAnotherTicket: 'Acheter un autre billet',
viewMyTickets: 'Voir dans Mes billets',
soldOut: 'Épuisé',
pastEvent: 'Cet événement est déjà passé',
loginToBuyTickets: 'Connectez-vous pour acheter des billets',
logIn: 'Connexion',
free: 'Gratuit',
organizer: "Organisateur",
location: "Lieu",
when: "Quand",
tickets: "Billets",
ticketsAvailable: "{count} billets disponibles",
ticketsOwned: "Vous avez {count} billet | Vous avez {count} billets",
unlimitedTickets: "Billets illimités",
buyTicket: "Acheter un billet",
buyAnotherTicket: "Acheter un autre billet",
viewMyTickets: "Voir dans Mes billets",
soldOut: "Épuisé",
pastEvent: "Cet événement est déjà passé",
loginToBuyTickets: "Connectez-vous pour acheter des billets",
logIn: "Connexion",
free: "Gratuit",
},
tickets: {
myTickets: 'Mes billets',
scanTicket: 'Scanner le billet',
noTickets: 'Pas encore de billets',
paid: 'Payé',
pending: 'En attente',
registered: 'Enregistré',
myTickets: "Mes billets",
scanTicket: "Scanner le billet",
noTickets: "Pas encore de billets",
paid: "Payé",
pending: "En attente",
registered: "Enregistré",
},
purchase: {
card: 'Carte',
continueTo: 'Continuer vers le paiement {provider}',
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.',
card: "Carte",
continueTo: "Continuer vers le paiement {provider}",
fiatRedirectHint:
"Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.",
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
fiatFailed: 'Le paiement par carte a échoué',
loginRequiredTitle: 'Connexion requise',
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
fiatFailed: "Le paiement par carte a échoué",
loginRequiredTitle: "Connexion requise",
loginRequiredFree:
"Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.",
promo: {
label: 'Code promo',
placeholder: 'Saisir le code',
apply: 'Appliquer',
remove: 'Retirer',
checking: 'Vérification…',
applied: '{code} −{percent}%',
discountLine: 'Remise ({code})',
label: "Code promo",
placeholder: "Saisir le code",
apply: "Appliquer",
remove: "Retirer",
checking: "Vérification…",
applied: "{code} −{percent}%",
discountLine: "Remise ({code})",
invalid: "Ce code n'est pas valable pour cet achat.",
unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.',
unverified:
"Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
loginRequiredDiscount:
"Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.",
},
contact: {
title: 'Vos coordonnées',
name: 'Nom',
namePlaceholder: 'Nom sur le billet',
nameRequired: 'Le nom est obligatoire',
email: 'E-mail',
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.',
emailInvalid: 'Saisissez une adresse e-mail valide',
title: "Vos coordonnées",
name: "Nom",
namePlaceholder: "Nom sur le billet",
nameRequired: "Le nom est obligatoire",
email: "E-mail",
emailHint: "Votre billet sera envoyé à cette adresse.",
spamHint:
"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",
emailMismatch: 'Les adresses e-mail ne correspondent pas',
emailMismatch: "Les adresses e-mail ne correspondent pas",
},
},
checkout: {
title: 'Confirmation de votre paiement…',
paidTitle: 'Paiement reçu',
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.',
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.',
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.',
emailedTo: 'Nous avons envoyé votre billet à {email}.',
cancelled: 'Paiement annulé — aucune place réservée.',
viewTicket: 'Ouvrir la page du billet',
viewMyTickets: 'Voir mes billets',
retry: 'Vérifier à nouveau',
ticketN: 'Billet {n} sur {total}',
close: 'Fermer',
title: "Confirmation de votre paiement…",
paidTitle: "Paiement reçu",
processing:
"En attente du règlement du paiement. Cela prend généralement quelques secondes.",
stillProcessing:
"Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.",
stillProcessingNoEmail:
"Toujours en cours. Rechargez cette page dans un instant.",
emailedTo: "Nous avons envoyé votre billet à {email}.",
cancelled: "Paiement annulé — aucune place réservée.",
viewTicket: "Ouvrir la page du billet",
viewMyTickets: "Voir mes billets",
retry: "Vérifier à nouveau",
ticketN: "Billet {n} sur {total}",
close: "Fermer",
},
ticket: {
title: 'Votre billet',
loading: 'Chargement du billet…',
notFound: 'Billet introuvable',
event: 'Événement',
holder: 'Nom',
paid: 'Payé',
pending: 'Paiement en attente',
registered: 'Enregistré',
title: "Votre billet",
loading: "Chargement du billet…",
notFound: "Billet introuvable",
event: "Événement",
holder: "Nom",
paid: "Payé",
pending: "Paiement en attente",
registered: "Enregistré",
showAtDoor: "Présentez ce QR à l'entrée.",
backToEvent: "Retour à l'événement",
},
nav: {
feed: 'Accueil',
calendar: 'Calendrier',
map: 'Carte',
favorites: 'Favoris',
settings: 'Réglages',
feed: "Accueil",
calendar: "Calendrier",
map: "Carte",
favorites: "Favoris",
settings: "Réglages",
},
search: {
placeholder: 'Rechercher des événements...',
noResults: 'Aucun événement trouvé',
placeholder: "Rechercher des événements...",
noResults: "Aucun événement trouvé",
},
favorites: {
title: 'Favoris',
loginPrompt: 'Connectez-vous pour sauvegarder vos événements préférés',
empty: 'Pas encore de favoris',
title: "Favoris",
loginPrompt: "Connectez-vous pour sauvegarder vos événements préférés",
empty: "Pas encore de favoris",
emptyHint: "Appuyez sur le cœur d'un événement pour le sauvegarder ici",
logIn: 'Se connecter',
logIn: "Se connecter",
},
settings: {
title: 'Réglages',
account: 'Compte',
loginPrompt: 'Connectez-vous pour sauvegarder des événements, confirmer votre présence et acheter des billets.',
logIn: 'Se connecter',
logOut: 'Se déconnecter',
appearance: 'Apparence',
theme: 'Thème',
language: 'Langue',
title: "Réglages",
account: "Compte",
loginPrompt:
"Connectez-vous pour sauvegarder des événements, confirmer votre présence et acheter des billets.",
logIn: "Se connecter",
logOut: "Se déconnecter",
appearance: "Apparence",
theme: "Thème",
language: "Langue",
},
},
libra: {
nav: {
record: 'Saisir',
transactions: 'Transactions',
balance: 'Solde',
wallet: 'Portefeuille',
settings: 'Param\u00e8tres',
record: "Saisir",
transactions: "Transactions",
balance: "Solde",
wallet: "Portefeuille",
settings: "Param\u00e8tres",
},
record: {
title: 'Saisir',
addExpense: 'Ajouter une d\u00e9pense',
addExpenseDescription: 'Soumettre une d\u00e9pense pour approbation',
addIncome: 'Ajouter un revenu',
addIncomeDescription: 'Enregistrer un revenu re\u00e7u',
comingSoon: 'Bient\u00f4t disponible',
drafts: 'Brouillons',
noDrafts: 'Aucun brouillon',
draftAge: 'Enregistr\u00e9 {time}',
saveDraft: 'Enregistrer le brouillon',
deleteDraft: 'Supprimer le brouillon',
title: "Saisir",
addExpense: "Ajouter une d\u00e9pense",
addExpenseDescription: "Soumettre une d\u00e9pense pour approbation",
addIncome: "Ajouter un revenu",
addIncomeDescription: "Enregistrer un revenu re\u00e7u",
comingSoon: "Bient\u00f4t disponible",
drafts: "Brouillons",
noDrafts: "Aucun brouillon",
draftAge: "Enregistr\u00e9 {time}",
saveDraft: "Enregistrer le brouillon",
deleteDraft: "Supprimer le brouillon",
},
balance: {
title: 'Solde',
netBalance: 'Solde net',
pending: 'En attente',
pendingExpenses: '{count} d\u00e9pense en attente | {count} d\u00e9penses en attente',
pendingAmount: '{amount} en attente d\u2019approbation',
noBalance: 'Aucune donn\u00e9e de solde disponible',
owedToYou: 'D\u00fb',
youOwe: 'Vous devez',
title: "Solde",
netBalance: "Solde net",
pending: "En attente",
pendingExpenses:
"{count} d\u00e9pense en attente | {count} d\u00e9penses en attente",
pendingAmount: "{amount} en attente d\u2019approbation",
noBalance: "Aucune donn\u00e9e de solde disponible",
owedToYou: "D\u00fb",
youOwe: "Vous devez",
},
settings: {
title: 'Param\u00e8tres',
account: 'Compte',
loginPrompt: 'Connectez-vous pour saisir des d\u00e9penses et voir votre solde.',
logIn: 'Se connecter',
logOut: 'Se d\u00e9connecter',
appearance: 'Apparence',
theme: 'Th\u00e8me',
language: 'Langue',
title: "Param\u00e8tres",
account: "Compte",
loginPrompt:
"Connectez-vous pour saisir des d\u00e9penses et voir votre solde.",
logIn: "Se connecter",
logOut: "Se d\u00e9connecter",
appearance: "Apparence",
theme: "Th\u00e8me",
language: "Langue",
},
income: {
title: 'Ajouter un revenu',
description: 'Soumettre un revenu pour l\u2019organisation',
selectAccount: 'S\u00e9lectionner le compte de revenus.',
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
submitIncome: 'Soumettre le revenu',
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
title: "Ajouter un revenu",
description: "Soumettre un revenu pour l\u2019organisation",
selectAccount: "S\u00e9lectionner le compte de revenus.",
otherAccountHint:
'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
submitIncome: "Soumettre le revenu",
notAvailable:
"La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.",
},
},
market: {
auth: {
loginPrompt: 'Connectez-vous pour passer commande',
logIn: 'Se connecter',
logInToCheckout: 'Se connecter pour commander',
nostrKeyRequired: 'Une identit\u00e9 Nostr est requise',
nostrKeyDescription: 'Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.',
loginPrompt: "Connectez-vous pour passer commande",
logIn: "Se connecter",
logInToCheckout: "Se connecter pour commander",
nostrKeyRequired: "Une identit\u00e9 Nostr est requise",
nostrKeyDescription:
"Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.",
},
},
dateTimeFormats: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
year: "numeric",
month: "short",
day: "numeric",
},
long: {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: 'numeric',
minute: 'numeric'
}
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
hour: "numeric",
minute: "numeric",
},
},
numberFormats: {
currency: {
style: 'currency',
currencyDisplay: 'symbol'
style: "currency",
currencyDisplay: "symbol",
},
decimal: {
style: 'decimal',
minimumFractionDigits: 2
style: "decimal",
minimumFractionDigits: 2,
},
percent: {
style: 'percent',
useGrouping: false
}
}
}
style: "percent",
useGrouping: false,
},
},
};
export default messages
export default messages;

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,74 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User, 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'
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { format } from "date-fns";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
MapPin,
Calendar,
Ticket,
User,
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<{
event: Event
event: Event;
/** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */
compact?: boolean
}>()
compact?: boolean;
}>();
const emit = defineEmits<{
click: [event: Event]
}>()
click: [event: Event];
}>();
const { t } = useI18n()
const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const { t } = useI18n();
const { dateLocale } = useDateLocale();
const { paidCount } = useOwnedTickets();
const ownedCount = computed(() => paidCount(props.event.id))
const ownedCount = computed(() => paidCount(props.event.id));
const dateDisplay = computed(() => {
const a = props.event
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
const a = props.event;
if (!a.startDate || isNaN(a.startDate.getTime())) return "";
try {
const opts = { locale: dateLocale.value }
if (a.type === 'date') {
return format(a.startDate, 'EEE, MMM d', opts)
const opts = { locale: dateLocale.value };
if (a.type === "date") {
return format(a.startDate, "EEE, MMM d", opts);
}
const date = format(a.startDate, 'EEE, MMM d', opts)
const time = format(a.startDate, 'HH:mm', opts)
return `${date} \u2022 ${time}`
const date = format(a.startDate, "EEE, MMM d", opts);
const time = format(a.startDate, "HH:mm", opts);
return `${date} \u2022 ${time}`;
} catch {
return ''
return "";
}
})
});
const categoryLabel = computed(() => {
if (!props.event.category) return null
return t(`events.categories.${props.event.category}`, props.event.category)
})
if (!props.event.category) return null;
return t(`events.categories.${props.event.category}`, props.event.category);
});
const priceDisplay = computed(() => {
const info = props.event.ticketInfo
if (!info) return null
if (info.price === 0) return t('events.detail.free')
return `${info.price} ${info.currency}`
})
const info = props.event.ticketInfo;
if (!info) return null;
if (info.price === 0) return t("events.detail.free");
return `${info.price} ${info.currency}`;
});
const isPast = computed(() => {
const a = props.event
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
const a = props.event;
const end = a.endDate ?? a.startDate;
if (!end || isNaN(end.getTime())) return false;
return end.getTime() < Date.now();
});
// 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.
const isNonApproved = computed(
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved',
)
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== "approved",
);
</script>
<template>
@ -92,187 +99,210 @@ const isNonApproved = computed(
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
vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. -->
<img
v-if="compact && event.image"
:src="event.image"
:alt="event.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
<!-- Image with overlaid badges. Cards without an image (or in
<img
v-if="compact && event.image"
:src="event.image"
:alt="event.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
<!-- Image with overlaid badges. Cards without an image (or in
compact mode) skip the hero area entirely and surface their
badges inline at the top of the content block — the solid-
color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. -->
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<img
:src="event.image"
: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"
<div
v-if="event.image && !compact"
class="relative aspect-[16/9] overflow-hidden rounded-t-lg"
>
{{ categoryLabel }}
</Badge>
<img
: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. -->
<Badge
v-if="event.isMine"
variant="outline"
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<User class="w-3 h-3" />
Yours
</Badge>
<Badge
v-if="event.isMine"
variant="outline"
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<User class="w-3 h-3" />
Yours
</Badge>
<!-- Price badge -->
<Badge
v-if="priceDisplay"
class="absolute top-2 right-2 text-xs"
>
{{ priceDisplay }}
</Badge>
<!-- Price badge -->
<Badge v-if="priceDisplay" class="absolute top-2 right-2 text-xs">
{{ 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
is now an absolute overlay on Card root, above the wash-out,
so we still suppress Past when isNonApproved (the status
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
v-if="isPast && !isNonApproved"
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" />
{{ t('events.filters.past', 'Past') }}
{{ t("events.filters.past", "Past") }}
</Badge>
</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
title to a single line. -->
<div class="flex items-start gap-1">
<h3
:class="[
'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
<div class="flex items-start gap-1">
<h3
:class="[
'font-semibold text-foreground leading-tight flex-1',
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 }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
{{ event.summary }}
</p>
<!-- Summary (hidden in compact mode) -->
<p
v-if="event.summary && !compact"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ 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
<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
it is) and on cards the user already owns. -->
<OrganizerCard
v-if="!compact"
:pubkey="event.organizer.pubkey"
compact
/>
<OrganizerCard
v-if="!compact"
:pubkey="event.organizer.pubkey"
compact
/>
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ dateDisplay }}</span>
</div>
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ dateDisplay }}</span>
</div>
<!-- Location -->
<div
v-if="event.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ event.location }}</span>
</div>
<!-- Location -->
<div
v-if="event.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ event.location }}</span>
</div>
<!-- Tickets available. `available === undefined` means
<!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. -->
<div
v-if="event.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }}
</span>
</div>
<div
v-if="event.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="event.ticketInfo.available === undefined">
{{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
</span>
<span v-else-if="event.ticketInfo.available > 0">
{{
t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available,
})
}}
</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
the availability line so the buyer can see at a glance
whether they've already bought in. -->
<div
v-if="ownedCount > 0"
class="flex items-center gap-1.5 text-sm text-primary"
>
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span>
{{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
</span>
<div
v-if="ownedCount > 0"
class="flex items-center gap-1.5 text-sm text-primary"
>
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span>
{{
t(
"events.detail.ticketsOwned",
{ count: ownedCount },
ownedCount,
)
}}
</span>
</div>
</div>
</div>
</CardContent>
</CardContent>
</div>
<!-- Status badge — absolutely positioned on Card root so it sits
@ -288,7 +318,7 @@ const isNonApproved = computed(
variant="destructive"
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>
</Card>
</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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes'
import {
computed,
ref,
toValue,
watch,
type MaybeRefOrGetter,
type Ref,
} from "vue";
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { TicketApiService } from "../services/TicketApiService";
import {
isUnsupportedEndpointError,
normalizePromoCode,
} from "../lib/promoCodes";
/**
* Buyer-side promo state for the purchase dialog.
@ -17,111 +27,138 @@ import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCode
* every extension version.
*/
export type PromoState =
| { status: 'idle' }
| { status: 'checking'; code: string }
| { status: "idle" }
| { status: "checking"; code: string }
| {
status: 'applied'
code: string
discountPercent: number
subtotal: number
discount: number
total: number
currency: string
status: "applied";
code: string;
discountPercent: number;
subtotal: number;
discount: number;
total: number;
currency: string;
}
| { status: 'invalid'; code: string }
| { status: 'unverified'; code: string }
| { status: "invalid"; 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>) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
export function usePromoCode(
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). */
const input = ref('')
const state = ref<PromoState>({ status: 'idle' })
const input = ref("");
const state = ref<PromoState>({ status: "idle" });
// Monotonic request counter so a slow response for a previous code or
// quantity can never overwrite a newer result.
let requestSeq = 0
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0;
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
* nothing so the buyer is not surprised by a 400 at pay time. */
const codeToSend = computed<string | undefined>(() => {
const s = state.value
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined
})
const s = state.value;
return s.status === "applied" || s.status === "unverified"
? s.code
: undefined;
});
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> {
const seq = ++requestSeq
state.value = { status: 'checking', code }
const seq = ++requestSeq;
state.value = { status: "checking", code };
try {
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value)
if (seq !== requestSeq) return
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0]
const totals = await ticketApi.validatePromoCode(
toValue(eventId),
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) {
state.value = { status: 'invalid', code }
return
state.value = { status: "invalid", code };
return;
}
state.value = {
status: 'applied',
status: "applied",
code: hit.code,
discountPercent: hit.discount_percent ?? 0,
subtotal: totals.subtotal,
discount: totals.discount,
total: totals.total,
currency: totals.currency,
}
};
} catch (err) {
if (seq !== requestSeq) return
if (seq !== requestSeq) return;
if (isUnsupportedEndpointError(err)) {
state.value = { status: 'unverified', code }
return
state.value = { status: "unverified", code };
return;
}
// Network / 5xx: don't block the purchase on an advisory call.
console.warn('[usePromoCode] validate failed, sending code unverified:', err)
state.value = { status: 'unverified', code }
console.warn(
"[usePromoCode] validate failed, sending code unverified:",
err,
);
state.value = { status: "unverified", code };
}
}
async function apply(): Promise<void> {
const code = normalizePromoCode(input.value)
if (!code) return
input.value = code
await validate(code)
const code = normalizePromoCode(input.value);
if (!code) return;
input.value = code;
await validate(code);
}
function clear(): void {
requestSeq++
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = null
input.value = ''
state.value = { status: 'idle' }
requestSeq++;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null;
input.value = "";
state.value = { status: "idle" };
}
/** Re-check the current code (quantity changed, or the purchase
* endpoint rejected it — e.g. the last use went to someone else). */
async function revalidate(): Promise<void> {
const s = state.value
if (s.status === 'idle' || s.status === 'checking') return
await validate(s.code)
const s = state.value;
if (s.status === "idle" || s.status === "checking") return;
await validate(s.code);
}
watch(quantity, () => {
const s = state.value
if (s.status === 'idle') return
if (debounceTimer) clearTimeout(debounceTimer)
const s = state.value;
if (s.status === "idle") return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null
void revalidate()
}, REVALIDATE_DEBOUNCE_MS)
})
debounceTimer = null;
void revalidate();
}, 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 { useAuth } from '@/composables/useAuthService'
import { toast } from 'vue-sonner'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
import { frontendRoot } from '../lib/frontendRoot'
import { renderTicketQr } from '../lib/ticketQr'
import { ref, computed, onUnmounted } from "vue";
import { useAuth } from "@/composables/useAuthService";
import { toast } from "vue-sonner";
import { useAsyncOperation } from "@/core/composables/useAsyncOperation";
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
import type { PaymentService } from "@/core/services/PaymentService";
import type { TicketApiService } from "../services/TicketApiService";
import { useOwnedTickets } from "./useOwnedTickets";
import { frontendRoot } from "../lib/frontendRoot";
import { renderTicketQr } from "../lib/ticketQr";
/** Guest identity for a purchase without an LNbits account. */
export interface PurchaseContact {
name: string
email: string
name: string;
email: string;
}
export interface PurchaseTicketOptions {
quantity?: number
quantity?: number;
/** Guest buyer (no account). Required when not authenticated. */
contact?: PurchaseContact
contact?: PurchaseContact;
/** 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). */
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() {
const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const { isAuthenticated, currentUser } = useAuth();
const paymentService = injectService(
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
// feed/calendar "My tickets" filter and EventCard owned badges update
// without a reload — purchase is exactly the "consumer that mutates
// the ticket set" useOwnedTickets's docs anticipate.
const { refresh: refreshOwnedTickets } = useOwnedTickets()
const { refresh: refreshOwnedTickets } = useOwnedTickets();
// Async operations
const purchaseOperation = useAsyncOperation()
const purchaseOperation = useAsyncOperation();
// State
const paymentHash = ref<string | null>(null)
const paymentRequest = ref<string | null>(null)
const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false)
const paymentHash = ref<string | null>(null);
const paymentRequest = ref<string | null>(null);
const qrCode = ref<string | null>(null);
const isPaymentPending = ref(false);
// Ticket QR code state. After payment lands, `purchasedTicketIds`
// 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
// attendee. `purchasedTicketId` stays for back-compat with the
// single-id success path.
const ticketQRCode = ref<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
const showTicketQR = ref(false)
const ticketQRCode = ref<string | null>(null);
const ticketQRCodes = ref<Record<string, string>>({});
const purchasedTicketId = ref<string | null>(null);
const purchasedTicketIds = ref<string[]>([]);
const showTicketQR = ref(false);
// Computed properties
const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
const canPurchase = computed(
() => isAuthenticated.value && currentUser.value,
);
const userDisplay = computed(() => {
if (!currentUser.value) return null
if (!currentUser.value) return null;
return {
name: currentUser.value.username || currentUser.value.id,
shortId: currentUser.value.id.slice(0, 8)
}
})
shortId: currentUser.value.id.slice(0, 8),
};
});
// Delegate to PaymentService
const userWallets = computed(() => paymentService.userWallets)
const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance)
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value)
const userWallets = computed(() => paymentService.userWallets);
const hasWalletWithBalance = computed(
() => paymentService.hasWalletWithBalance,
);
const isPayingWithWallet = computed(
() => paymentService.isProcessingPayment.value,
);
async function generateQRCode(bolt11: string) {
try {
qrCode.value = await paymentService.generateQRCode(bolt11)
qrCode.value = await paymentService.generateQRCode(bolt11);
} catch (err) {
console.error('Error generating QR code:', err)
console.error("Error generating QR code:", err);
}
}
async function generateTicketQRCode(ticketId: string) {
try {
const dataUrl = await renderTicketQr(ticketId, { width: 160 })
ticketQRCode.value = dataUrl
return dataUrl
const dataUrl = await renderTicketQr(ticketId, { width: 160 });
ticketQRCode.value = dataUrl;
return dataUrl;
} catch (error) {
console.error('Error generating ticket QR code:', error)
return null
console.error("Error generating ticket QR code:", error);
return null;
}
}
async function payWithWallet(pr: string) {
try {
await paymentService.payWithWallet(pr, undefined, {
showToast: false
})
return true
showToast: false,
});
return true;
} catch (error) {
console.error('Wallet payment failed:', error)
throw error
console.error("Wallet payment failed:", error);
throw error;
}
}
/** The event id this composable is currently driving — kept so
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* 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
@ -118,26 +131,26 @@ export function useTicketPurchase() {
// Ticket row(s) now exist — refresh the shared owned-tickets state so
// the feed/calendar My-tickets filter and owned badges reflect the
// purchase immediately (no reload). Runs in parallel with QR gen.
void refreshOwnedTickets()
void refreshOwnedTickets();
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
purchasedTicketIds.value = ids;
purchasedTicketId.value = ids[0];
const qrMap: Record<string, string> = {};
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
const dataUrl = await generateTicketQRCode(id);
if (dataUrl) qrMap[id] = dataUrl;
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true
ticketQRCodes.value = qrMap;
ticketQRCode.value = qrMap[ids[0]] ?? null;
showTicketQR.value = true;
}
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
: "Ticket purchased successfully!",
);
}
async function purchaseTicketForEvent(
@ -147,78 +160,82 @@ export function useTicketPurchase() {
// Guests are identified by name + email; logged-in buyers by user id.
// Either is enough for a paid ticket — free tickets stay login-gated
// in the dialog (events#29: no anonymous free issuance).
const isGuest = !canPurchase.value || !currentUser.value
const isGuest = !canPurchase.value || !currentUser.value;
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 () => {
// Clear previous state
paymentHash.value = null
paymentRequest.value = null
qrCode.value = null
ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false
currentEventId.value = eventId
return await purchaseOperation.execute(
async () => {
// Clear previous state
paymentHash.value = null;
paymentRequest.value = null;
qrCode.value = null;
ticketQRCode.value = null;
ticketQRCodes.value = {};
purchasedTicketId.value = null;
purchasedTicketIds.value = [];
showTicketQR.value = false;
currentEventId.value = eventId;
// Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
// Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any;
const accessToken = lnbitsAPI?.getAccessToken?.() || "";
const invoice = await ticketApi.requestTicket(eventId, {
userId: isGuest ? undefined : currentUser.value!.id,
name: isGuest ? options.contact?.name : undefined,
email: isGuest ? options.contact?.email : options.email,
accessToken: isGuest ? undefined : accessToken,
frontendUrl: frontendRoot(),
quantity: options.quantity,
promoCode: options.promoCode,
})
const invoice = await ticketApi.requestTicket(eventId, {
userId: isGuest ? undefined : currentUser.value!.id,
name: isGuest ? options.contact?.name : undefined,
email: isGuest ? options.contact?.email : options.email,
accessToken: isGuest ? undefined : accessToken,
frontendUrl: frontendRoot(),
quantity: options.quantity,
promoCode: options.promoCode,
ticketWaveId: options.ticketWaveId,
});
// Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip
// the QR / payment-poll and jump straight to the ticket-QR success
// state with the ids returned inline.
if (invoice.paid) {
paymentHash.value = invoice.paymentHash
await finalizePurchasedTickets(invoice.ticketIds ?? [])
return invoice
}
// Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip
// the QR / payment-poll and jump straight to the ticket-QR success
// state with the ids returned inline.
if (invoice.paid) {
paymentHash.value = invoice.paymentHash;
await finalizePurchasedTickets(invoice.ticketIds ?? []);
return invoice;
}
// Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent
// broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
'to follow the provider link.',
)
}
const bolt11: string = invoice.paymentRequest
paymentHash.value = invoice.paymentHash
paymentRequest.value = bolt11
// Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent
// broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
"This event uses fiat checkout. Use the purchase dialog " +
"to follow the provider link.",
);
}
const bolt11: string = invoice.paymentRequest;
paymentHash.value = invoice.paymentHash;
paymentRequest.value = bolt11;
// Generate QR code for payment
await generateQRCode(bolt11)
// Generate QR code for payment
await generateQRCode(bolt11);
// Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash)
// Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash);
return invoice
}, {
errorMessage: 'Failed to purchase ticket'
})
return invoice;
},
{
errorMessage: "Failed to purchase ticket",
},
);
}
/**
@ -227,76 +244,77 @@ export function useTicketPurchase() {
* my LNbits wallet" button on the invoice screen.
*/
async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return
await payWithWallet(paymentRequest.value)
if (!paymentRequest.value) return;
await payWithWallet(paymentRequest.value);
// Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to
// restart it here.
}
async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true
let checkInterval: number | null = null
isPaymentPending.value = true;
let checkInterval: number | null = null;
const checkPayment = async () => {
try {
const result = await ticketApi.checkPaymentStatus(eventId, hash)
const result = await ticketApi.checkPaymentStatus(eventId, hash);
if (result.paid) {
isPaymentPending.value = false
isPaymentPending.value = false;
if (checkInterval) {
clearInterval(checkInterval)
clearInterval(checkInterval);
}
// Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include
// `ticketId` only.
const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds
: result.ticketId
? [result.ticketId]
: []
await finalizePurchasedTickets(ids)
const ids =
result.ticketIds && result.ticketIds.length > 0
? result.ticketIds
: result.ticketId
? [result.ticketId]
: [];
await finalizePurchasedTickets(ids);
}
} catch (err) {
console.error('Error checking payment status:', err)
console.error("Error checking payment status:", err);
}
}
};
await checkPayment()
checkInterval = setInterval(checkPayment, 2000) as unknown as number
await checkPayment();
checkInterval = setInterval(checkPayment, 2000) as unknown as number;
}
function stopPaymentStatusCheck() {
isPaymentPending.value = false
isPaymentPending.value = false;
}
function resetPaymentState() {
purchaseOperation.clear()
paymentHash.value = null
paymentRequest.value = null
qrCode.value = null
isPaymentPending.value = false
ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false
purchaseOperation.clear();
paymentHash.value = null;
paymentRequest.value = null;
qrCode.value = null;
isPaymentPending.value = false;
ticketQRCode.value = null;
ticketQRCodes.value = {};
purchasedTicketId.value = null;
purchasedTicketIds.value = [];
showTicketQR.value = false;
}
function handleOpenLightningWallet() {
if (paymentRequest.value) {
paymentService.openExternalWallet(paymentRequest.value)
paymentService.openExternalWallet(paymentRequest.value);
}
}
function cleanup() {
stopPaymentStatusCheck()
stopPaymentStatusCheck();
}
onUnmounted(() => {
cleanup()
})
cleanup();
});
return {
// State
@ -329,6 +347,6 @@ export function useTicketPurchase() {
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
// (backend returns `paid: true`, no checkout URL) on the same
// success screen as the Lightning / free paths.
finalizePurchasedTickets
}
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 { TicketApiService } from './TicketApiService'
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TicketApiService } from "./TicketApiService";
const BASE = 'https://lnbits.test'
const BASE = "https://lnbits.test";
function okJson(body: unknown) {
return { ok: true, json: async () => body } as Response
return { ok: true, json: async () => body } as Response;
}
describe('TicketApiService.requestTicket', () => {
const fetchMock = vi.fn()
let api: TicketApiService
describe("TicketApiService.requestTicket", () => {
const fetchMock = vi.fn();
let api: TicketApiService;
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
})
afterEach(() => vi.unstubAllGlobals())
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
});
afterEach(() => vi.unstubAllGlobals());
function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
return {
url,
init,
body: JSON.parse(init.body as string),
headers: init.headers as Record<string, string>,
};
}
it('sends name + email and no user_id / Authorization for guests', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] }))
const res = await api.requestTicket('evt', {
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events',
paymentMethod: 'fiat', quantity: 2,
})
const { url, body, headers } = lastCall()
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`)
it("sends name + email and no user_id / Authorization for guests", async () => {
fetchMock.mockResolvedValue(
okJson({
payment_hash: "h",
fiat_payment_request: "https://checkout",
is_fiat: true,
ticket_ids: ["a", "b"],
}),
);
const res = await api.requestTicket("evt", {
name: "Guest",
email: "g@example.com",
frontendUrl: "https://demo.aiolabs.dev/events",
paymentMethod: "fiat",
quantity: 2,
});
const { url, body, headers } = lastCall();
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`);
expect(body).toEqual({
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
payment_method: 'fiat', quantity: 2,
})
expect(body.user_id).toBeUndefined()
expect(headers.Authorization).toBeUndefined()
expect(headers['X-API-KEY']).toBe('inst-key')
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] })
})
name: "Guest",
email: "g@example.com",
frontend_url: "https://demo.aiolabs.dev/events",
payment_method: "fiat",
quantity: 2,
});
expect(body.user_id).toBeUndefined();
expect(headers.Authorization).toBeUndefined();
expect(headers["X-API-KEY"]).toBe("inst-key");
expect(res).toMatchObject({
isFiat: true,
fiatPaymentRequest: "https://checkout",
ticketIds: ["a", "b"],
});
});
it('sends user_id + email with a Bearer token for logged-in buyers', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
await api.requestTicket('evt', {
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173',
})
const { body, headers } = lastCall()
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' })
expect(headers.Authorization).toBe('Bearer tok')
})
it("sends user_id + email with a Bearer token for logged-in buyers", async () => {
fetchMock.mockResolvedValue(
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
);
await api.requestTicket("evt", {
userId: "u1",
email: "me@example.com",
accessToken: "tok",
frontendUrl: "http://localhost:5173",
});
const { body, headers } = lastCall();
expect(body).toEqual({
user_id: "u1",
email: "me@example.com",
frontend_url: "http://localhost:5173",
});
expect(headers.Authorization).toBe("Bearer tok");
});
it('surfaces the backend detail string as the error message', async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response)
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' }))
.rejects.toThrow('Payment method not enabled for this event.')
})
})
it("surfaces the backend detail string as the error message", async () => {
fetchMock.mockResolvedValue({
ok: false,
json: async () => ({
detail: "Payment method not enabled for this event.",
}),
} as Response);
await expect(
api.requestTicket("evt", {
name: "G",
email: "g@x.io",
paymentMethod: "fiat",
}),
).rejects.toThrow("Payment method not enabled for this event.");
});
});
describe('TicketApiService.getTicket / resendTicketEmail', () => {
const fetchMock = vi.fn()
let api: TicketApiService
describe("TicketApiService.getTicket / resendTicketEmail", () => {
const fetchMock = vi.fn();
let api: TicketApiService;
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
})
afterEach(() => vi.unstubAllGlobals())
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: "k" });
});
afterEach(() => vi.unstubAllGlobals());
it('maps the anonymous PublicTicket shape', async () => {
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' }))
const t = await api.getTicket('tid')
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`)
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' })
})
it("maps the anonymous PublicTicket shape", async () => {
fetchMock.mockResolvedValue(
okJson({
event: "evt",
name: "G",
registered: false,
paid: true,
time: "t",
reg_timestamp: "r",
}),
);
const t = await api.getTicket("tid");
expect(fetchMock.mock.calls[0][0]).toBe(
`${BASE}/events/api/v1/tickets/tid`,
);
expect(t).toEqual({
eventId: "evt",
name: "G",
registered: false,
paid: true,
time: "t",
regTimestamp: "r",
});
});
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => {
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} }
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } }))
const r = await api.resendTicketEmail('tid', 'admin')
expect(r.ticket.id).toBe('tid')
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' })
expect(r.nostr.attempted).toBe(false)
it("maps the structured resend result and tolerates the legacy bare-ticket shape", async () => {
const ticket = {
id: "tid",
wallet: "w",
event: "evt",
name: "G",
email: "g@x.io",
user_id: null,
registered: false,
paid: true,
time: "t",
reg_timestamp: "r",
extra: {},
};
fetchMock.mockResolvedValueOnce(
okJson({
ticket,
email: { attempted: true, sent: false, error: "smtp down" },
nostr: { attempted: false, sent: false, error: null },
}),
);
const r = await api.resendTicketEmail("tid", "admin");
expect(r.ticket.id).toBe("tid");
expect(r.email).toEqual({
attempted: true,
sent: false,
error: "smtp down",
});
expect(r.nostr.attempted).toBe(false);
fetchMock.mockResolvedValueOnce(okJson(ticket))
const legacy = await api.resendTicketEmail('tid', 'admin')
expect(legacy.ticket.id).toBe('tid')
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
})
})
fetchMock.mockResolvedValueOnce(okJson(ticket));
const legacy = await api.resendTicketEmail("tid", "admin");
expect(legacy.ticket.id).toBe("tid");
expect(legacy.email).toEqual({
attempted: false,
sent: false,
error: null,
});
});
});
describe('TicketApiService promo codes', () => {
const fetchMock = vi.fn()
let api: TicketApiService
describe("TicketApiService promo codes", () => {
const fetchMock = vi.fn();
let api: TicketApiService;
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
})
afterEach(() => vi.unstubAllGlobals())
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
});
afterEach(() => vi.unstubAllGlobals());
function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
return {
url,
init,
body: JSON.parse(init.body as string),
headers: init.headers as Record<string, string>,
};
}
it('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 = {
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }],
}
fetchMock.mockResolvedValue(okJson(totals))
const res = await api.validatePromoCode('evt', ' half ', 2)
const { url, init, body } = lastCall()
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`)
expect(init.method).toBe('POST')
expect(body).toEqual({ codes: ['HALF'], quantity: 2 })
expect(res).toEqual(totals)
})
subtotal: 2000,
discount: 1000,
total: 1000,
currency: "sat",
discounts_applied: [
{
code: "HALF",
discount_percent: 50,
discount_fixed: null,
amount_saved: 1000,
},
],
};
fetchMock.mockResolvedValue(okJson(totals));
const res = await api.validatePromoCode("evt", " half ", 2);
const { url, init, body } = lastCall();
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`);
expect(init.method).toBe("POST");
expect(body).toEqual({ codes: ["HALF"], quantity: 2 });
expect(res).toEqual(totals);
});
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response)
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 })
})
it("carries the HTTP status on errors so a missing route (older extension) is recognisable", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ detail: "Not Found" }),
} as Response);
await expect(api.validatePromoCode("evt", "HALF")).rejects.toMatchObject({
message: "Not Found",
status: 404,
});
});
it('requestTicket forwards promo_code verbatim', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
})
it("requestTicket forwards promo_code verbatim", async () => {
fetchMock.mockResolvedValue(
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
);
await api.requestTicket("evt", {
userId: "u1",
promoCode: "HALF",
quantity: 2,
});
expect(lastCall().body).toEqual({
user_id: "u1",
promo_code: "HALF",
quantity: 2,
});
});
it('createEvent sends extra.promo_codes verbatim', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
it("requestTicket names the chosen ticket wave", async () => {
// Without it the backend refuses ("Please select a ticket wave")
// 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(
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
'inkey',
)
const { body, headers } = lastCall()
expect(body.extra.promo_codes).toEqual(promo_codes)
expect(headers['X-API-KEY']).toBe('inkey')
})
})
{ name: "E", event_start_date: "2030-01-01", extra: { promo_codes } },
"inkey",
);
const { body, headers } = lastCall();
expect(body.extra.promo_codes).toEqual(promo_codes);
expect(headers["X-API-KEY"]).toBe("inkey");
});
});

View file

@ -11,33 +11,36 @@ import type {
TicketedEvent,
CreateEventRequest,
PromoValidateRequest,
} from '../types/ticket'
import { normalizePromoCode } from '../lib/promoCodes'
} from "../types/ticket";
import { normalizePromoCode } from "../lib/promoCodes";
export interface TicketApiConfig {
baseUrl: string
apiKey: string
baseUrl: string;
apiKey: string;
}
/** Options for `TicketApiService.requestTicket`. */
export interface RequestTicketOptions {
/** LNbits user id — logged-in buyers. */
userId?: string
userId?: string;
/** Guest buyer name (required together with `email` when no `userId`). */
name?: string
name?: string;
/** Delivery email — required for guests, optional for logged-in buyers. */
email?: string
email?: string;
/** Bearer token; forwarded only when present. */
accessToken?: string
accessToken?: string;
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
frontendUrl?: string
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
frontendUrl?: string;
paymentMethod?: PaymentMethod;
fiatProvider?: string;
promoCode?: string;
refundAddress?: string;
nostrIdentifier?: string;
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
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.
*/
async fetchTicketedEvents(): Promise<any[]> {
const response = await this.request(
'/events/api/v1/events/public',
{ method: 'GET' }
)
return response
const response = await this.request("/events/api/v1/events/public", {
method: "GET",
});
return response;
}
/**
@ -71,10 +73,10 @@ export class TicketApiService {
* own event the moment it drops to `proposed`.
*/
async fetchMyEvents(invoiceKey: string): Promise<any[]> {
return this.request('/events/api/v1/events?all_wallets=true', {
method: 'GET',
headers: { 'X-API-KEY': invoiceKey },
})
return this.request("/events/api/v1/events?all_wallets=true", {
method: "GET",
headers: { "X-API-KEY": invoiceKey },
});
}
/**
@ -99,29 +101,32 @@ export class TicketApiService {
eventId: string,
options: RequestTicketOptions = {},
): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = {}
if (options.userId) body.user_id = options.userId
if (options.name) body.name = options.name
if (options.email) body.email = options.email
if (options.frontendUrl) body.frontend_url = options.frontendUrl
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const body: CreateTicketRequest = {};
if (options.userId) body.user_id = options.userId;
if (options.name) body.name = options.name;
if (options.email) body.email = options.email;
if (options.frontendUrl) body.frontend_url = options.frontendUrl;
if (options.paymentMethod) body.payment_method = options.paymentMethod;
if (options.fiatProvider) body.fiat_provider = options.fiatProvider;
if (options.promoCode) body.promo_code = options.promoCode;
if (options.refundAddress) body.refund_address = options.refundAddress;
if (options.nostrIdentifier)
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' }
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.accessToken)
headers["Authorization"] = `Bearer ${options.accessToken}`;
const data = await this.request(
`/events/api/v1/tickets/${eventId}`,
{
method: 'POST',
headers,
body: JSON.stringify(body),
}
)
const data = await this.request(`/events/api/v1/tickets/${eventId}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return {
paymentHash: data.payment_hash,
@ -133,7 +138,7 @@ export class TicketApiService {
// and no invoice to settle.
paid: Boolean(data.paid),
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.
*/
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 {
eventId: t.event,
name: t.name ?? null,
@ -150,7 +157,7 @@ export class TicketApiService {
paid: Boolean(t.paid),
time: t.time,
regTimestamp: t.reg_timestamp,
}
};
}
/**
@ -160,7 +167,7 @@ export class TicketApiService {
* failure here as non-fatal.
*/
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(
eventId: string,
paymentHash: string
paymentHash: string,
): Promise<TicketPaymentStatus> {
const data = await this.request(
`/events/api/v1/tickets/${eventId}/${paymentHash}`,
{ method: 'POST' }
)
{ method: "POST" },
);
return {
paid: data.paid === true,
ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
}
};
}
/**
@ -187,19 +194,16 @@ export class TicketApiService {
*/
async fetchUserTickets(
userId: string,
accessToken: string
accessToken: string,
): Promise<EventTicket[]> {
const data = await this.request(
`/events/api/v1/tickets/user/${userId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
)
const data = await this.request(`/events/api/v1/tickets/user/${userId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return (data as any[]).map(t => ({
return (data as any[]).map((t) => ({
id: t.id,
wallet: t.wallet,
eventId: t.event,
@ -211,7 +215,7 @@ export class TicketApiService {
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined,
}))
}));
}
/**
@ -220,10 +224,10 @@ export class TicketApiService {
async validateTicket(ticketId: string): Promise<EventTicket[]> {
const data = await this.request(
`/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,
wallet: t.wallet,
eventId: t.event,
@ -235,22 +239,25 @@ export class TicketApiService {
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined,
}))
}));
}
/**
* Create a new event. Uses invoice key (any user).
* Backend determines approval status based on user role and settings.
*/
async createEvent(eventData: CreateEventRequest, invoiceKey: string): Promise<TicketedEvent> {
return this.request('/events/api/v1/events', {
method: 'POST',
async createEvent(
eventData: CreateEventRequest,
invoiceKey: string,
): Promise<TicketedEvent> {
return this.request("/events/api/v1/events", {
method: "POST",
headers: {
'Content-Type': 'application/json',
'X-API-KEY': invoiceKey,
"Content-Type": "application/json",
"X-API-KEY": invoiceKey,
},
body: JSON.stringify(eventData),
})
});
}
/**
@ -265,13 +272,13 @@ export class TicketApiService {
adminKey: string,
): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, {
method: 'PUT',
method: "PUT",
headers: {
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
"Content-Type": "application/json",
"X-API-KEY": adminKey,
},
body: JSON.stringify(eventData),
})
});
}
/**
@ -289,16 +296,16 @@ export class TicketApiService {
const r = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
const t = r.ticket ?? r
method: "POST",
headers: { "X-API-KEY": adminKey },
},
);
const t = r.ticket ?? r;
const delivery = (d: any) => ({
attempted: Boolean(d?.attempted),
sent: Boolean(d?.sent),
error: d?.error ?? null,
})
});
return {
ticket: {
id: t.id,
@ -315,7 +322,7 @@ export class TicketApiService {
},
email: delivery(r.email),
nostr: delivery(r.nostr),
}
};
}
/**
@ -329,23 +336,23 @@ export class TicketApiService {
eventId: string,
adminKey: string,
): Promise<{
event_id: string
sold: number
registered: number
remaining: number
event_id: string;
sold: number;
registered: number;
remaining: number;
tickets: Array<{
id: string
name?: string | null
registered: boolean
registered_at: string | null
id: string;
name?: string | null;
registered: boolean;
registered_at: string | null;
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
applied_promo_code?: string | null
}>
applied_promo_code?: string | null;
}>;
}> {
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
method: 'GET',
headers: { 'X-API-KEY': adminKey },
})
method: "GET",
headers: { "X-API-KEY": adminKey },
});
}
/**
@ -355,13 +362,24 @@ export class TicketApiService {
* means "not valid". Older extensions 404 — the thrown error carries
* `status` so callers can fall back to "applied at checkout".
*/
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
async validatePromoCode(
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}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
});
}
/**
@ -370,11 +388,17 @@ export class TicketApiService {
* unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message.
*/
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> {
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
method: 'PUT',
headers: { 'X-API-KEY': adminKey },
})
async registerTicket(
ticketId: string,
adminKey: string,
): Promise<EventTicket> {
const t = await this.request(
`/events/api/v1/tickets/register/${ticketId}`,
{
method: "PUT",
headers: { "X-API-KEY": adminKey },
},
);
return {
id: t.id,
wallet: t.wallet,
@ -387,7 +411,7 @@ export class TicketApiService {
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined,
}
};
}
/**
@ -397,13 +421,13 @@ export class TicketApiService {
*/
async isAdmin(adminKey: string): Promise<boolean> {
try {
await this.request('/events/api/v1/events/all', {
method: 'GET',
headers: { 'X-API-KEY': adminKey },
})
return true
await this.request("/events/api/v1/events/all", {
method: "GET",
headers: { "X-API-KEY": adminKey },
});
return true;
} catch {
return false
return false;
}
}
@ -415,13 +439,16 @@ export class TicketApiService {
*/
async getAutoApprove(invoiceKey: string): Promise<boolean> {
try {
const settings = await this.request('/events/api/v1/events/settings/public', {
method: 'GET',
headers: { 'X-API-KEY': invoiceKey },
})
return Boolean(settings?.auto_approve)
const settings = await this.request(
"/events/api/v1/events/settings/public",
{
method: "GET",
headers: { "X-API-KEY": invoiceKey },
},
);
return Boolean(settings?.auto_approve);
} catch {
return false
return false;
}
}
@ -430,13 +457,15 @@ export class TicketApiService {
*/
async getCurrencies(): Promise<string[]> {
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)) {
return ['sats', ...currencies.filter((c: string) => c !== 'sats')]
return ["sats", ...currencies.filter((c: string) => c !== "sats")];
}
return ['sats']
return ["sats"];
} catch {
return ['sats']
return ["sats"];
}
}
@ -445,28 +474,31 @@ export class TicketApiService {
*/
private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
...(init.headers as Record<string, string> ?? {}),
}
accept: "application/json",
"X-API-KEY": this.config.apiKey,
...((init.headers as Record<string, string>) ?? {}),
};
const response = await fetch(`${this.config.baseUrl}${path}`, {
...init,
headers,
})
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` }))
const errorMessage = typeof error.detail === 'string'
? error.detail
: Array.isArray(error.detail)
? error.detail[0]?.msg ?? 'Request failed'
: 'Request failed'
const error = await response
.json()
.catch(() => ({ detail: `Request failed: ${path}` }));
const errorMessage =
typeof error.detail === "string"
? 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
// 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 type { EventCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { TicketedEvent } from './ticket'
import ngeohash from "ngeohash";
import type { EventCategory } from "./category";
import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from "./nip52";
import type { TicketedEvent } from "./ticket";
/**
* Unified view model for displaying events in the UI.
@ -9,43 +9,43 @@ import type { TicketedEvent } from './ticket'
*/
export interface Event {
/** Unique identifier (NIP-52 d-tag) */
id: string
id: string;
/** Nostr event ID */
nostrEventId: string
nostrEventId: string;
/** Whether this is a date-only or time-specific event */
type: 'date' | 'time'
type: "date" | "time";
/** Organizer information */
organizer: OrganizerInfo
organizer: OrganizerInfo;
/** Event title */
title: string
title: string;
/** Brief summary */
summary?: string
summary?: string;
/** Full description (markdown) */
description: string
description: string;
/** Banner/poster image URL */
image?: string
image?: string;
/** Start date/time */
startDate: Date
startDate: Date;
/** End date/time */
endDate?: Date
endDate?: Date;
/** Timezone identifier (IANA) */
timezone?: string
timezone?: string;
/** Human-readable location */
location?: string
location?: string;
/** Geographic coordinates (derived from geohash) */
coordinates?: { lat: number; lng: number }
coordinates?: { lat: number; lng: number };
/** NIP-52 geohash (g tag) */
geohash?: string
geohash?: string;
/** Primary category */
category?: EventCategory
category?: EventCategory;
/** All hashtags/tags */
tags: string[]
tags: string[];
/** Ticket pricing info (if ticketed) */
ticketInfo?: EventTicketInfo
ticketInfo?: EventTicketInfo;
/** Whether this is a private/invite-only event */
isPrivate: boolean
isPrivate: boolean;
/** Nostr event created_at timestamp */
createdAt: Date
createdAt: Date;
/**
* LNbits approval status, when the event came from the 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"
* 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
* 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
* at a glance.
*/
isMine?: boolean
isMine?: boolean;
}
export interface OrganizerInfo {
pubkey: string
name?: string
picture?: string
nip05?: string
pubkey: string;
name?: string;
picture?: string;
nip05?: string;
}
export interface EventTicketInfo {
price: number
currency: string
price: number;
currency: string;
/** Remaining capacity. Undefined means unlimited. */
available?: number
/** Running paid count. */
sold: number
/** Total capacity (available + sold). Undefined means unlimited. */
total?: number
available?: number;
/** Running paid count across every wave. */
sold: number;
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
allowFiat: boolean;
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
fiatCurrency?: string;
/** Rails the organizer enabled (see TicketTags.paymentMethods). */
paymentMethods?: string[]
paymentMethods?: string[];
}
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
if (!ticket) return undefined
function ticketTagsToInfo(
ticket: TicketTags | undefined,
): EventTicketInfo | undefined {
if (!ticket) return undefined;
return {
price: ticket.price,
currency: ticket.currency,
available: ticket.available,
sold: ticket.sold,
// Capacity isn't published directly; derive it from remaining + sold.
// Undefined `available` means unlimited, so total stays undefined too.
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
// No total. It used to be derived as `available + sold`, which was
// already wrong after any organizer edit re-baselined remaining
// (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,
fiatCurrency: ticket.fiatCurrency,
paymentMethods: ticket.paymentMethods,
}
};
}
/**
* Convert a CalendarTimeEvent to an Event view model
*/
export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: Partial<OrganizerInfo>): Event {
const category = event.hashtags[0] as EventCategory | undefined
export function calendarTimeEventToEvent(
event: CalendarTimeEvent,
organizer?: Partial<OrganizerInfo>,
): Event {
const category = event.hashtags[0] as EventCategory | undefined;
return {
id: event.dTag,
nostrEventId: event.id,
type: 'time',
type: "time",
organizer: {
pubkey: event.pubkey,
...organizer,
@ -133,25 +139,28 @@ export function calendarTimeEventToEvent(event: CalendarTimeEvent, organizer?: P
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false,
createdAt: new Date(event.createdAt * 1000),
}
};
}
/**
* Convert a CalendarDateEvent to an Event view model
*/
export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: Partial<OrganizerInfo>): Event {
const category = event.hashtags[0] as EventCategory | undefined
export function calendarDateEventToEvent(
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
const parseIsoDate = (dateStr: string): Date => {
const [year, month, day] = dateStr.split('-').map(Number)
return new Date(Date.UTC(year, month - 1, day))
}
const [year, month, day] = dateStr.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
};
return {
id: event.dTag,
nostrEventId: event.id,
type: 'date',
type: "date",
organizer: {
pubkey: event.pubkey,
...organizer,
@ -170,7 +179,7 @@ export function calendarDateEventToEvent(event: CalendarDateEvent, organizer?: P
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false,
createdAt: new Date(event.createdAt * 1000),
}
};
}
/**
@ -189,18 +198,18 @@ export function ticketedEventToEvent(
event: TicketedEvent,
organizer?: Partial<OrganizerInfo>,
): Event {
const hasTime = event.event_start_date.includes('T')
const hasTime = event.event_start_date.includes("T");
const startDate = hasTime
? new Date(event.event_start_date)
: parseDateOnly(event.event_start_date)
const endRaw = event.event_end_date
: parseDateOnly(event.event_start_date);
const endRaw = event.event_end_date;
const endDate = endRaw
? endRaw.includes('T')
? endRaw.includes("T")
? new Date(endRaw)
: parseDateOnly(endRaw)
: undefined
: undefined;
const category = event.categories?.[0] as EventCategory | undefined
const category = event.categories?.[0] as EventCategory | undefined;
return {
id: event.id,
@ -208,16 +217,16 @@ export function ticketedEventToEvent(
// id as a placeholder. Approved + published versions will overwrite
// this with the real Nostr event id.
nostrEventId: event.id,
type: hasTime ? 'time' : 'date',
type: hasTime ? "time" : "date",
organizer: {
// Pending events have no Nostr pubkey yet. Empty string is fine
// — the card layer falls back gracefully and the OrganizerCard
// is only shown for approved (Nostr-sourced) events anyway.
pubkey: '',
pubkey: "",
...organizer,
},
title: event.name,
description: event.info ?? '',
description: event.info ?? "",
image: event.banner ?? undefined,
startDate,
endDate,
@ -229,24 +238,26 @@ export function ticketedEventToEvent(
// FastAPI serialization). new Date() handles both ISO strings and
// numeric epoch — same shape used in useEvents sorting.
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
// reaching this adapter is by definition mine.
isMine: true,
}
};
}
function parseDateOnly(dateStr: string): Date {
const [year, month, day] = dateStr.split('-').map(Number)
return new Date(Date.UTC(year, month - 1, day))
const [year, month, day] = dateStr.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
if (!geohash) return undefined
function decodeGeohash(
geohash?: string,
): { lat: number; lng: number } | undefined {
if (!geohash) return undefined;
try {
const { latitude, longitude } = ngeohash.decode(geohash)
return { lat: latitude, lng: longitude }
const { latitude, longitude } = ngeohash.decode(geohash);
return { lat: latitude, lng: longitude };
} catch {
return undefined
return undefined;
}
}

View file

@ -7,41 +7,45 @@
*/
export interface PromoCode {
code: string
discount_percent: number
active: boolean
code: string;
discount_percent: number;
active: boolean;
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
* same name as upstream v2). */
max_uses?: number | null
max_uses?: number | null;
/** Paid tickets that used this code — derived by the backend on read,
* never sent back. */
used_count?: number
used_count?: number;
}
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
* v2 shape; `quantity` stands in for v2's `items` since this fork has
* no ticket types). */
export interface PromoValidateRequest {
codes: string[]
quantity: number
codes: string[];
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 {
code: string
discount_percent: number | null
code: string;
discount_percent: number | null;
/** Always null on this fork (percent-only); kept for the v2 shape. */
discount_fixed: number | null
amount_saved: number
discount_fixed: number | null;
amount_saved: number;
}
/** Response of the promo validate endpoint. An empty `discounts_applied`
* means none of the submitted codes is valid for this event/quantity. */
export interface BasketTotals {
subtotal: number
discount: number
total: number
discounts_applied: BasketDiscount[]
currency: string
subtotal: number;
discount: number;
total: number;
discounts_applied: BasketDiscount[];
currency: string;
}
/**
@ -50,73 +54,101 @@ export interface BasketTotals {
* and the per-event notification toggles + custom subject/body added
* 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 {
/** Present on organizer-authenticated records only: public event
* responses (`/events/public`, `/events/{id}`) no longer carry the
* code list (events ext >= v1.6.1-aio.12). */
promo_codes?: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
promo_codes?: PromoCode[];
conditional: boolean;
min_tickets: number;
email_notifications: boolean;
nostr_notifications: boolean;
notification_subject: string;
notification_body: string;
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */
payment_methods?: string[]
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 {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
nostr_identifier?: string | null
ticket_base_url?: string | null
email_notification_sent: boolean
nostr_notification_sent: boolean
refunded: boolean
applied_promo_code?: string | null;
sats_paid?: number | null;
refund_address?: string | null;
nostr_identifier?: string | null;
ticket_base_url?: string | null;
email_notification_sent: boolean;
nostr_notification_sent: boolean;
refunded: boolean;
}
export interface EventTicket {
id: string
wallet: string
id: string;
wallet: string;
/** Reference to the event (LNbits event ID) */
eventId: string
eventId: string;
/** Ticket holder name */
name: string | null
name: string | null;
/** Ticket holder email */
email: string | null
email: string | null;
/** Nostr pubkey or LNbits user ID */
userId: string | null
userId: string | null;
/** Whether ticket has been scanned/registered at the door */
registered: boolean
registered: boolean;
/** Whether payment has been confirmed */
paid: boolean
paid: boolean;
/** Ticket creation timestamp */
time: string
time: string;
/** Registration/scan timestamp */
regTimestamp: string
regTimestamp: string;
/** Optional metadata — promo code applied, sats paid, notification
* 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 {
eventId: string
userId: string
accessToken: string
eventId: string;
userId: string;
accessToken: string;
/** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */
paymentMethod?: PaymentMethod
paymentMethod?: PaymentMethod;
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
* 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.
*/
export interface TicketPurchaseInvoice {
paymentHash: string
paymentRequest?: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
paymentHash: string;
paymentRequest?: string;
fiatPaymentRequest?: string;
fiatProvider?: string;
isFiat: boolean;
/** Free tickets: already issued + paid, no invoice to settle. */
paid?: boolean
paid?: boolean;
/** Row ids returned inline for the free path (no poll needed). */
ticketIds?: string[]
ticketIds?: string[];
}
export interface TicketPaymentStatus {
paid: boolean
paid: boolean;
/** First ticket id created on this invoice. Back-compat with
* single-ticket purchases — equals the payment_hash. */
ticketId?: string
ticketId?: string;
/** Every row created on this invoice — one for single-ticket
* purchases, N for multi-ticket. Each row is independently
* 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.
*/
export interface TicketedEvent {
id: string
wallet: string
name: string
info: string
closing_date: string | null
event_start_date: string
event_end_date: string | null
currency: string
id: string;
wallet: string;
name: string;
info: string;
closing_date: string | null;
event_start_date: string;
event_end_date: string | null;
currency: string;
/** 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: string
amount_tickets: number
price_per_ticket: number
time: string
sold: number
banner: string | null
location: string | null
categories: string[]
extra: EventExtra
status: string
fiat_currency: string;
amount_tickets: number;
price_per_ticket: number;
time: string;
sold: number;
banner: string | null;
location: string | null;
categories: string[];
extra: EventExtra;
status: string;
}
export interface CreateEventRequest {
wallet?: string
name: string
info?: string
closing_date?: string
event_start_date: string
event_end_date?: string
currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number
price_per_ticket?: number
banner?: string | null
location?: string | null
categories?: string[]
wallet?: string;
name: string;
info?: string;
closing_date?: string;
event_start_date: string;
event_end_date?: string;
currency?: string;
allow_fiat?: boolean;
fiat_currency?: string;
amount_tickets?: number;
price_per_ticket?: number;
banner?: string | null;
location?: string | null;
categories?: string[];
/** Optional — notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */
extra?: Partial<EventExtra>
extra?: Partial<EventExtra>;
}
/**
@ -214,19 +246,23 @@ export interface CreateEventRequest {
* under the webapp instead of the LNbits host.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
name?: string;
email?: string;
user_id?: string;
/** App root (origin + base path, no trailing slash). Origin must be
* on the LNbits instance's CORS allowlist or the backend 400s. */
frontend_url?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
frontend_url?: string;
promo_code?: string;
refund_address?: string;
nostr_identifier?: string;
payment_method?: PaymentMethod;
fiat_provider?: string;
/** 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.
*/
export interface PublicTicket {
eventId: string
name: string | null
registered: boolean
paid: boolean
time: string
regTimestamp: string
eventId: string;
name: string | null;
registered: boolean;
paid: boolean;
time: string;
regTimestamp: string;
}
export interface NotificationDeliveryResult {
attempted: boolean
sent: boolean
error: string | null
attempted: boolean;
sent: boolean;
error: string | null;
}
/**
@ -254,7 +290,7 @@ export interface NotificationDeliveryResult {
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
*/
export interface TicketResendResult {
ticket: EventTicket
email: NotificationDeliveryResult
nostr: NotificationDeliveryResult
ticket: EventTicket;
email: NotificationDeliveryResult;
nostr: NotificationDeliveryResult;
}

View file

@ -1,117 +1,157 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useElementVisibility } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { useDateLocale } from '../composables/useDateLocale'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { computed, onMounted, ref, watch } from "vue";
import { useElementVisibility } from "@vueuse/core";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { format } from "date-fns";
import { useDateLocale } from "../composables/useDateLocale";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
} from 'lucide-vue-next'
import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue'
import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout'
import { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets'
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'
Calendar,
MapPin,
ArrowLeft,
Pencil,
Ticket,
CheckCircle2,
ScanLine,
History,
} from "lucide-vue-next";
import { useEventDetail } from "../composables/useEventDetail";
import BookmarkButton from "../components/BookmarkButton.vue";
import OrganizerCard from "../components/OrganizerCard.vue";
import PurchaseTicketDialog from "../components/PurchaseTicketDialog.vue";
import CheckoutReturnDialog from "../components/CheckoutReturnDialog.vue";
import {
clearPendingCheckout,
readPendingCheckout,
} from "../lib/pendingCheckout";
import { useAuth } from "@/composables/useAuthService";
import { useEventsStore } from "../stores/events";
import { useOwnedTickets } from "../composables/useOwnedTickets";
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 router = useRouter()
const { t } = useI18n()
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const eventId = route.params.id as string
const { event, isLoading, error, reload } = useEventDetail(eventId)
const { dateLocale } = useDateLocale()
const eventId = route.params.id as string;
const { event, isLoading, error, reload } = useEventDetail(eventId);
const { dateLocale } = useDateLocale();
// 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
// nostr_publisher.build_nip52_event). Look the user's own events up
// once and offer an Edit button on a match.
const { isAuthenticated, currentUser } = useAuth()
const eventsStore = useEventsStore()
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
const { isAuthenticated, currentUser } = useAuth();
const eventsStore = useEventsStore();
const ownedLnbitsEvent = ref<TicketedEvent | null>(null);
async function loadOwnedEvent() {
ownedLnbitsEvent.value = null
if (!isAuthenticated.value) return
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!invoiceKey) return
ownedLnbitsEvent.value = null;
if (!isAuthenticated.value) return;
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey;
if (!invoiceKey) return;
try {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const mine = await ticketApi.fetchMyEvents(invoiceKey)
const ticketApi = injectService(
SERVICE_TOKENS.TICKET_API,
) as TicketApiService;
const mine = await ticketApi.fetchMyEvents(invoiceKey);
ownedLnbitsEvent.value =
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null;
} catch {
ownedLnbitsEvent.value = null
ownedLnbitsEvent.value = null;
}
}
onMounted(loadOwnedEvent)
watch(isAuthenticated, () => loadOwnedEvent())
// Ticket waves live on the LNbits record, not in the NIP-52 tags: the
// 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() {
if (!ownedLnbitsEvent.value) return
eventsStore.editingEvent = ownedLnbitsEvent.value
eventsStore.showCreateDialog = true
if (!ownedLnbitsEvent.value) return;
eventsStore.editingEvent = ownedLnbitsEvent.value;
eventsStore.showCreateDialog = true;
}
function openScannerPage() {
router.push({ name: 'scan-tickets', params: { eventId } })
router.push({ name: "scan-tickets", params: { eventId } });
}
const dateDisplay = computed(() => {
if (!event.value) return ''
const a = event.value
const opts = { locale: dateLocale.value }
if (a.type === 'date') {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
if (!event.value) return "";
const a = event.value;
const opts = { locale: dateLocale.value };
if (a.type === "date") {
const start = format(a.startDate, "EEEE, MMMM d, yyyy", opts);
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,
// 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
// ("May 29 • 19:00 — May 30 • 21:45").
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm'
const start = format(a.startDate, FULL, opts)
if (!a.endDate) return start
const FULL = "EEEE, MMMM d, yyyy \u2022 HH:mm";
const start = format(a.startDate, FULL, opts);
if (!a.endDate) return start;
const sameDay =
a.startDate.getFullYear() === a.endDate.getFullYear() &&
a.startDate.getMonth() === a.endDate.getMonth() &&
a.startDate.getDate() === a.endDate.getDate()
return `${start} — ${format(a.endDate, sameDay ? 'HH:mm' : FULL, opts)}`
})
a.startDate.getDate() === a.endDate.getDate();
return `${start} — ${format(a.endDate, sameDay ? "HH:mm" : FULL, opts)}`;
});
const categoryLabel = computed(() => {
if (!event.value?.category) return null
return t(`events.categories.${event.value.category}`, event.value.category)
})
if (!event.value?.category) return null;
return t(`events.categories.${event.value.category}`, event.value.category);
});
function goBack() {
router.push({ name: 'events' })
router.push({ name: "events" });
}
// --- 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 a = event.value
if (!a || !a.ticketInfo) return null
const a = event.value;
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 {
id: a.id,
name: a.title,
@ -120,45 +160,52 @@ const purchaseEvent = computed(() => {
allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency,
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 === 0 → sold out, button hidden
// available > 0 → button shown
const canBuyTicket = computed(() => {
const info = event.value?.ticketInfo
if (!info) return false
return info.available === undefined || info.available > 0
})
const info = event.value?.ticketInfo;
if (!info) return false;
return info.available === undefined || info.available > 0;
});
// 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
// on a long detail page.
const isPast = computed(() => {
const a = event.value
if (!a) return false
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
const a = event.value;
if (!a) return false;
const end = a.endDate ?? a.startDate;
if (!end || isNaN(end.getTime())) return false;
return end.getTime() < Date.now();
});
const showPurchaseDialog = ref(false)
const showPurchaseDialog = ref(false);
function openPurchaseDialog() {
// Paid tickets are open to guests (name + email identity). Free
// claims still need an account so seats can't be drained anonymously.
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0;
if (!isAuthenticated.value && isFreeEvent) {
toastService.info(t('events.detail.loginToBuyTickets'), {
toastService.info(t("events.detail.loginToBuyTickets"), {
action: {
label: t('events.detail.logIn'),
onClick: () => router.push('/login'),
label: t("events.detail.logIn"),
onClick: () => router.push("/login"),
},
})
return
});
return;
}
showPurchaseDialog.value = true
showPurchaseDialog.value = true;
}
// --- Return from the fiat provider ---------------------------------
@ -167,36 +214,37 @@ function openPurchaseDialog() {
// or ?checkout=cancelled. The query is stripped immediately so the
// ticket ids don't linger in history; the localStorage record written
// before the redirect is the fallback source and carries the email.
const showReturnDialog = ref(false)
const returnTicketIds = ref<string[]>([])
const returnEmail = ref<string | undefined>(undefined)
const showReturnDialog = ref(false);
const returnTicketIds = ref<string[]>([]);
const returnEmail = ref<string | undefined>(undefined);
function handleCheckoutReturn() {
const status = route.query.checkout
if (status !== 'success' && status !== 'cancelled') return
const status = route.query.checkout;
if (status !== "success" && status !== "cancelled") return;
const pending = readPendingCheckout()
const samePurchase = pending?.eventId === eventId
void router.replace({ query: {} })
const pending = readPendingCheckout();
const samePurchase = pending?.eventId === eventId;
void router.replace({ query: {} });
if (status === 'cancelled') {
clearPendingCheckout()
toastService.info(t('events.checkout.cancelled'))
return
if (status === "cancelled") {
clearPendingCheckout();
toastService.info(t("events.checkout.cancelled"));
return;
}
const fromQuery = String(route.query.tickets ?? '')
.split(',')
const fromQuery = String(route.query.tickets ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : []
if (ids.length === 0) return
returnTicketIds.value = ids
returnEmail.value = samePurchase ? pending?.email : undefined
showReturnDialog.value = true
.filter(Boolean);
const ids =
fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [];
if (ids.length === 0) return;
returnTicketIds.value = ids;
returnEmail.value = samePurchase ? pending?.email : undefined;
showReturnDialog.value = true;
}
onMounted(handleCheckoutReturn)
onMounted(handleCheckoutReturn);
// --- Sticky buy bar --------------------------------------------------
// 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
// 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).
const ticketBlockRef = ref<HTMLElement | null>(null)
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
const ticketBlockRef = ref<HTMLElement | null>(null);
const ticketBlockVisible = useElementVisibility(ticketBlockRef);
const priceLabel = computed(() => {
const info = event.value?.ticketInfo
if (!info) return ''
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
})
const info = event.value?.ticketInfo;
if (!info) return "";
return info.price === 0
? t("events.detail.free")
: `${info.price} ${info.currency}`;
});
const buyLabel = computed(() =>
ownedPaidCount.value > 0
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('events.detail.buyTicket', 'Buy ticket'),
)
? t("events.detail.buyAnotherTicket", "Buy another ticket")
: t("events.detail.buyTicket", "Buy ticket"),
);
const showStickyCta = computed(
() =>
@ -228,22 +278,25 @@ const showStickyCta = computed(
!ticketBlockVisible.value &&
!showPurchaseDialog.value &&
!showReturnDialog.value,
)
);
// Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension.
watch(showPurchaseDialog, (open) => {
if (!open) refreshOwnedTickets()
})
if (!open) refreshOwnedTickets();
});
function goToMyTickets() {
router.push('/my-tickets')
router.push("/my-tickets");
}
</script>
<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
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
@ -264,7 +317,9 @@ function goToMyTickets() {
<!-- Error -->
<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>
<Button variant="outline" @click="reload">Retry</Button>
</div>
@ -286,11 +341,7 @@ function goToMyTickets() {
<!-- "Yours" leads the row in the highlighted variant so the
ownership signal stands out against the neutral
category/tag chips that follow. -->
<Badge
v-if="event.isMine"
variant="secondary"
class="shrink-0"
>
<Badge v-if="event.isMine" variant="secondary" class="shrink-0">
Yours
</Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
@ -298,10 +349,14 @@ function goToMyTickets() {
</Badge>
<Badge
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
:variant="
event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'
"
class="shrink-0 capitalize"
>
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
{{
event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review"
}}
</Badge>
<div v-for="tag in event.tags.slice(1)" :key="tag">
<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" />
<span>
{{ dateDisplay }}
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
<span v-if="event.timezone" class="opacity-70"
>({{ event.timezone }})</span
>
</span>
</div>
<div v-if="event.location" class="flex items-start gap-1.5">
@ -364,7 +421,7 @@ function goToMyTickets() {
@click="openScannerPage"
>
<ScanLine class="w-4 h-4" />
{{ t('events.detail.scanTickets', 'Scan tickets') }}
{{ t("events.detail.scanTickets", "Scan tickets") }}
</Button>
<!-- Tickets — gated on the event carrying ticketInfo (set
@ -384,13 +441,21 @@ function goToMyTickets() {
v-if="ownedPaidCount > 0"
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" />
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
{{
t(
"events.detail.ticketsOwned",
{ count: ownedPaidCount },
ownedPaidCount,
)
}}
</div>
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
{{ t("events.detail.viewMyTickets", "View in My Tickets") }}
</Button>
</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"
>
<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 v-else-if="canBuyTicket" class="space-y-1">
<Button
@ -414,10 +479,14 @@ function goToMyTickets() {
</Button>
<p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
{{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
</span>
<span v-else>
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
{{
t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available,
})
}}
</span>
</p>
</div>
@ -425,7 +494,7 @@ function goToMyTickets() {
v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center"
>
{{ t('events.detail.soldOut') }}
{{ t("events.detail.soldOut") }}
</p>
</div>
@ -458,50 +527,68 @@ function goToMyTickets() {
parent's space-y margin (margins offset fixed boxes too) and
float 24px above the nav. -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
leave-active-class="transition duration-150 ease-in"
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')"
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
leave-active-class="transition duration-150 ease-in"
leave-to-class="translate-y-4 opacity-0"
>
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
<p class="text-xs text-muted-foreground truncate">
<span v-if="ownedPaidCount > 0">
{{ 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.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"
<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"
>
<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 class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">
{{ event.title }}
</p>
<p class="text-xs text-muted-foreground truncate">
<span v-if="ownedPaidCount > 0">
{{
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>
</Transition>
</Transition>
</Teleport>
<!-- External references -->
@ -513,8 +600,10 @@ function goToMyTickets() {
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
{{ t('events.detail.organizer') }}
<p
class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"
>
{{ t("events.detail.organizer") }}
</p>
<OrganizerCard :pubkey="event.organizer.pubkey" />
</div>