Ticket waves: manage them, buy from them, stop lying about totals #176

Merged
padreug merged 4 commits from feat/ticket-waves into dev 2026-09-29 07:23:44 +00:00
7 changed files with 1366 additions and 1292 deletions
Showing only changes of commit 072ca8817b - Show all commits

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.
Padreug 2026-09-29 08:30:03 +02:00

View file

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

View file

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

View file

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

View file

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

View file

@ -1,74 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from "vue";
import { useI18n } from 'vue-i18n' import { useI18n } from "vue-i18n";
import { format } from 'date-fns' import { format } from "date-fns";
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from "@/components/ui/card";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next' import {
import BookmarkButton from './BookmarkButton.vue' MapPin,
import OrganizerCard from './OrganizerCard.vue' Calendar,
import { useDateLocale } from '../composables/useDateLocale' Ticket,
import { useOwnedTickets } from '../composables/useOwnedTickets' User,
import type { Event } from '../types/event' CheckCircle2,
History,
} from "lucide-vue-next";
import BookmarkButton from "./BookmarkButton.vue";
import OrganizerCard from "./OrganizerCard.vue";
import { useDateLocale } from "../composables/useDateLocale";
import { useOwnedTickets } from "../composables/useOwnedTickets";
import type { Event } from "../types/event";
const props = defineProps<{ const props = defineProps<{
event: Event event: Event;
/** Render a compact row: no hero image, no summary, single-line /** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the * title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */ * host already knows what their events look like. */
compact?: boolean compact?: boolean;
}>() }>();
const emit = defineEmits<{ const emit = defineEmits<{
click: [event: Event] click: [event: Event];
}>() }>();
const { t } = useI18n() const { t } = useI18n();
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale();
const { paidCount } = useOwnedTickets() const { paidCount } = useOwnedTickets();
const ownedCount = computed(() => paidCount(props.event.id)) const ownedCount = computed(() => paidCount(props.event.id));
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.event const a = props.event;
if (!a.startDate || isNaN(a.startDate.getTime())) return '' if (!a.startDate || isNaN(a.startDate.getTime())) return "";
try { try {
const opts = { locale: dateLocale.value } const opts = { locale: dateLocale.value };
if (a.type === 'date') { if (a.type === "date") {
return format(a.startDate, 'EEE, MMM d', opts) return format(a.startDate, "EEE, MMM d", opts);
} }
const date = format(a.startDate, 'EEE, MMM d', opts) const date = format(a.startDate, "EEE, MMM d", opts);
const time = format(a.startDate, 'HH:mm', opts) const time = format(a.startDate, "HH:mm", opts);
return `${date} \u2022 ${time}` return `${date} \u2022 ${time}`;
} catch { } catch {
return '' return "";
} }
}) });
const categoryLabel = computed(() => { const categoryLabel = computed(() => {
if (!props.event.category) return null if (!props.event.category) return null;
return t(`events.categories.${props.event.category}`, props.event.category) return t(`events.categories.${props.event.category}`, props.event.category);
}) });
const priceDisplay = computed(() => { const priceDisplay = computed(() => {
const info = props.event.ticketInfo const info = props.event.ticketInfo;
if (!info) return null if (!info) return null;
if (info.price === 0) return t('events.detail.free') if (info.price === 0) return t("events.detail.free");
return `${info.price} ${info.currency}` return `${info.price} ${info.currency}`;
}) });
const isPast = computed(() => { const isPast = computed(() => {
const a = props.event const a = props.event;
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate;
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false;
return end.getTime() < Date.now() return end.getTime() < Date.now();
}) });
// Pending / rejected events get a washed-out look so the user // Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge. // sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed( const isNonApproved = computed(
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved', () => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== "approved",
) );
</script> </script>
<template> <template>
@ -108,7 +115,10 @@ const isNonApproved = computed(
badges inline at the top of the content block — the solid- badges inline at the top of the content block — the solid-
color placeholder + calendar glyph wasn't communicating color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. --> anything the title + details don't already. -->
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg"> <div
v-if="event.image && !compact"
class="relative aspect-[16/9] overflow-hidden rounded-t-lg"
>
<img <img
:src="event.image" :src="event.image"
:alt="event.title" :alt="event.title"
@ -137,10 +147,7 @@ const isNonApproved = computed(
</Badge> </Badge>
<!-- Price badge --> <!-- Price badge -->
<Badge <Badge v-if="priceDisplay" class="absolute top-2 right-2 text-xs">
v-if="priceDisplay"
class="absolute top-2 right-2 text-xs"
>
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </Badge>
@ -155,25 +162,36 @@ const isNonApproved = computed(
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur" class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
<History class="w-3 h-3" /> <History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }} {{ t("events.filters.past", "Past") }}
</Badge> </Badge>
</div> </div>
<CardContent <CardContent
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'" :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 <!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. --> 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"> <div
v-if="!event.image || compact"
class="flex flex-wrap items-center gap-1.5"
>
<Badge v-if="categoryLabel" variant="secondary" class="text-xs"> <Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </Badge>
<Badge v-if="priceDisplay" class="text-xs"> <Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </Badge>
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1"> <Badge
v-if="event.isMine && !compact"
variant="outline"
class="text-xs gap-1"
>
<User class="w-3 h-3" /> <User class="w-3 h-3" />
Yours Yours
</Badge> </Badge>
@ -183,7 +201,7 @@ const isNonApproved = computed(
class="text-xs gap-1" class="text-xs gap-1"
> >
<History class="w-3 h-3" /> <History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }} {{ t("events.filters.past", "Past") }}
</Badge> </Badge>
</div> </div>
@ -214,7 +232,9 @@ const isNonApproved = computed(
{{ event.summary }} {{ event.summary }}
</p> </p>
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'"> <div
:class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'"
>
<!-- Organizer — small avatar + display name. Hidden in compact <!-- Organizer — small avatar + display name. Hidden in compact
mode (host's own roster, no need to tell them whose event mode (host's own roster, no need to tell them whose event
it is) and on cards the user already owns. --> it is) and on cards the user already owns. -->
@ -248,13 +268,17 @@ const isNonApproved = computed(
> >
<Ticket class="w-3.5 h-3.5 shrink-0" /> <Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="event.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
</span> </span>
<span v-else-if="event.ticketInfo.available > 0"> <span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }} {{
t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available,
})
}}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }} {{ t("events.detail.soldOut") }}
</span> </span>
</div> </div>
@ -268,7 +292,13 @@ const isNonApproved = computed(
> >
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" /> <CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span> <span>
{{ t('events.detail.ticketsOwned', { count: ownedCount }, ownedCount) }} {{
t(
"events.detail.ticketsOwned",
{ count: ownedCount },
ownedCount,
)
}}
</span> </span>
</div> </div>
</div> </div>
@ -288,7 +318,7 @@ const isNonApproved = computed(
variant="destructive" variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow" class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
> >
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review" }}
</Badge> </Badge>
</Card> </Card>
</template> </template>

View file

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

View file

@ -483,9 +483,8 @@ function goToMyTickets() {
</span> </span>
<span v-else> <span v-else>
{{ {{
t("events.detail.ticketsRemainingOfTotal", { t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available, count: event.ticketInfo.available,
total: event.ticketInfo.total,
}) })
}} }}
</span> </span>
@ -567,9 +566,8 @@ function goToMyTickets() {
<span v-else> <span v-else>
{{ priceLabel }} · {{ priceLabel }} ·
{{ {{
t("events.detail.ticketsRemainingOfTotal", { t("events.detail.ticketsAvailable", {
count: event.ticketInfo.available, count: event.ticketInfo.available,
total: event.ticketInfo.total,
}) })
}} }}
</span> </span>