Compare commits
4 commits
9ad8d42e58
...
072ca8817b
| Author | SHA1 | Date | |
|---|---|---|---|
| 072ca8817b | |||
| d55115d0ac | |||
| afa34c90c2 | |||
| 8490cdf84c |
17 changed files with 4498 additions and 2628 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,308 +1,307 @@
|
||||||
export interface LocaleMessages {
|
export interface LocaleMessages {
|
||||||
nav: {
|
nav: {
|
||||||
title: string
|
title: string;
|
||||||
home: string
|
home: string;
|
||||||
directory: string
|
directory: string;
|
||||||
faq: string
|
faq: string;
|
||||||
events: string
|
events: string;
|
||||||
market: string
|
market: string;
|
||||||
chat: string
|
chat: string;
|
||||||
login: string
|
login: string;
|
||||||
logout: string
|
logout: string;
|
||||||
}
|
};
|
||||||
// Add more message categories here
|
// Add more message categories here
|
||||||
common: {
|
common: {
|
||||||
loading: string
|
loading: string;
|
||||||
error: string
|
error: string;
|
||||||
success: string
|
success: string;
|
||||||
nav: {
|
nav: {
|
||||||
profile: string
|
profile: string;
|
||||||
preferences: string
|
preferences: string;
|
||||||
profileDescription: string
|
profileDescription: string;
|
||||||
profileLoggedOutDescription: string
|
profileLoggedOutDescription: string;
|
||||||
login: string
|
login: string;
|
||||||
menu: string
|
menu: string;
|
||||||
backToHub: string
|
backToHub: string;
|
||||||
hub: string
|
hub: string;
|
||||||
theme: string
|
theme: string;
|
||||||
themeLight: string
|
themeLight: string;
|
||||||
themeDark: string
|
themeDark: string;
|
||||||
themeSystem: string
|
themeSystem: string;
|
||||||
colorScheme: string
|
colorScheme: string;
|
||||||
palette_catppuccin: string
|
palette_catppuccin: string;
|
||||||
palette_countrysidecastle: string
|
palette_countrysidecastle: string;
|
||||||
palette_darkmatter: string
|
palette_darkmatter: string;
|
||||||
palette_emeraldforest: string
|
palette_emeraldforest: string;
|
||||||
palette_lightgreen: string
|
palette_lightgreen: string;
|
||||||
palette_neobrut: string
|
palette_neobrut: string;
|
||||||
palette_starrynight: string
|
palette_starrynight: string;
|
||||||
language: string
|
language: string;
|
||||||
currency: string
|
currency: string;
|
||||||
currencyComingSoon: string
|
currencyComingSoon: string;
|
||||||
currencyComingSoonDescription: string
|
currencyComingSoonDescription: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
errors: {
|
errors: {
|
||||||
notFound: string
|
notFound: string;
|
||||||
serverError: string
|
serverError: string;
|
||||||
networkError: string
|
networkError: string;
|
||||||
}
|
};
|
||||||
// Language names in the current language
|
// Language names in the current language
|
||||||
locales: {
|
locales: {
|
||||||
en: string
|
en: string;
|
||||||
es: string
|
es: string;
|
||||||
fr: string
|
fr: string;
|
||||||
de: string
|
de: string;
|
||||||
zh: string
|
zh: string;
|
||||||
}
|
};
|
||||||
// Events module
|
// Events module
|
||||||
events?: {
|
events?: {
|
||||||
title: string
|
title: string;
|
||||||
createNew: string
|
createNew: string;
|
||||||
create: {
|
create: {
|
||||||
promo: {
|
promo: {
|
||||||
title: string
|
title: string;
|
||||||
hint: string
|
hint: string;
|
||||||
empty: string
|
empty: string;
|
||||||
code: string
|
code: string;
|
||||||
codePlaceholder: string
|
codePlaceholder: string;
|
||||||
discount: string
|
discount: string;
|
||||||
maxUses: string
|
maxUses: string;
|
||||||
active: string
|
active: string;
|
||||||
used: string
|
used: string;
|
||||||
add: string
|
add: string;
|
||||||
removeLabel: string
|
removeLabel: string;
|
||||||
errors: {
|
errors: {
|
||||||
required: string
|
required: string;
|
||||||
duplicate: string
|
duplicate: string;
|
||||||
percentRange: string
|
percentRange: string;
|
||||||
maxUsesMin: string
|
maxUsesMin: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
noEvents: string
|
noEvents: string;
|
||||||
filters: {
|
filters: {
|
||||||
all: string
|
all: string;
|
||||||
today: string
|
today: string;
|
||||||
tomorrow: string
|
tomorrow: string;
|
||||||
thisWeek: string
|
thisWeek: string;
|
||||||
thisMonth: string
|
thisMonth: string;
|
||||||
myTickets: string
|
myTickets: string;
|
||||||
hosting: string
|
hosting: string;
|
||||||
pastEvents: string
|
pastEvents: string;
|
||||||
past: string
|
past: string;
|
||||||
filters: string
|
filters: string;
|
||||||
clearAll: string
|
clearAll: string;
|
||||||
filteringBy: string
|
filteringBy: string;
|
||||||
removeCategory: string
|
removeCategory: string;
|
||||||
}
|
};
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>;
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: string
|
getTicket: string;
|
||||||
contactOrganizer: string
|
contactOrganizer: string;
|
||||||
organizer: string
|
organizer: string;
|
||||||
location: string
|
location: string;
|
||||||
when: string
|
when: string;
|
||||||
tickets: string
|
tickets: string;
|
||||||
ticketsAvailable: string
|
ticketsAvailable: string;
|
||||||
ticketsRemainingOfTotal: string
|
ticketsOwned: string;
|
||||||
ticketsOwned: string
|
unlimitedTickets: string;
|
||||||
unlimitedTickets: string
|
buyTicket: string;
|
||||||
buyTicket: string
|
buyAnotherTicket: string;
|
||||||
buyAnotherTicket: string
|
viewMyTickets: string;
|
||||||
viewMyTickets: string
|
soldOut: string;
|
||||||
soldOut: string
|
pastEvent: string;
|
||||||
pastEvent: string
|
loginToBuyTickets: string;
|
||||||
loginToBuyTickets: string
|
logIn: string;
|
||||||
logIn: string
|
free: string;
|
||||||
free: string
|
};
|
||||||
}
|
|
||||||
tickets: {
|
tickets: {
|
||||||
myTickets: string
|
myTickets: string;
|
||||||
scanTicket: string
|
scanTicket: string;
|
||||||
noTickets: string
|
noTickets: string;
|
||||||
paid: string
|
paid: string;
|
||||||
pending: string
|
pending: string;
|
||||||
registered: string
|
registered: string;
|
||||||
}
|
};
|
||||||
purchase: {
|
purchase: {
|
||||||
card: string
|
card: string;
|
||||||
continueTo: string
|
continueTo: string;
|
||||||
fiatRedirectHint: string
|
fiatRedirectHint: string;
|
||||||
noCheckoutUrl: string
|
noCheckoutUrl: string;
|
||||||
fiatFailed: string
|
fiatFailed: string;
|
||||||
loginRequiredTitle: string
|
loginRequiredTitle: string;
|
||||||
loginRequiredFree: string
|
loginRequiredFree: string;
|
||||||
promo: {
|
promo: {
|
||||||
label: string
|
label: string;
|
||||||
placeholder: string
|
placeholder: string;
|
||||||
apply: string
|
apply: string;
|
||||||
remove: string
|
remove: string;
|
||||||
checking: string
|
checking: string;
|
||||||
applied: string
|
applied: string;
|
||||||
discountLine: string
|
discountLine: string;
|
||||||
invalid: string
|
invalid: string;
|
||||||
unverified: string
|
unverified: string;
|
||||||
loginRequiredDiscount: string
|
loginRequiredDiscount: string;
|
||||||
}
|
};
|
||||||
contact: {
|
contact: {
|
||||||
title: string
|
title: string;
|
||||||
name: string
|
name: string;
|
||||||
namePlaceholder: string
|
namePlaceholder: string;
|
||||||
nameRequired: string
|
nameRequired: string;
|
||||||
email: string
|
email: string;
|
||||||
emailHint: string
|
emailHint: string;
|
||||||
spamHint: string
|
spamHint: string;
|
||||||
emailInvalid: string
|
emailInvalid: string;
|
||||||
confirmEmail: string
|
confirmEmail: string;
|
||||||
emailMismatch: string
|
emailMismatch: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
checkout: {
|
checkout: {
|
||||||
title: string
|
title: string;
|
||||||
paidTitle: string
|
paidTitle: string;
|
||||||
processing: string
|
processing: string;
|
||||||
stillProcessing: string
|
stillProcessing: string;
|
||||||
stillProcessingNoEmail: string
|
stillProcessingNoEmail: string;
|
||||||
emailedTo: string
|
emailedTo: string;
|
||||||
cancelled: string
|
cancelled: string;
|
||||||
viewTicket: string
|
viewTicket: string;
|
||||||
viewMyTickets: string
|
viewMyTickets: string;
|
||||||
retry: string
|
retry: string;
|
||||||
ticketN: string
|
ticketN: string;
|
||||||
close: string
|
close: string;
|
||||||
}
|
};
|
||||||
ticket: {
|
ticket: {
|
||||||
title: string
|
title: string;
|
||||||
loading: string
|
loading: string;
|
||||||
notFound: string
|
notFound: string;
|
||||||
event: string
|
event: string;
|
||||||
holder: string
|
holder: string;
|
||||||
paid: string
|
paid: string;
|
||||||
pending: string
|
pending: string;
|
||||||
registered: string
|
registered: string;
|
||||||
showAtDoor: string
|
showAtDoor: string;
|
||||||
backToEvent: string
|
backToEvent: string;
|
||||||
}
|
};
|
||||||
nav: {
|
nav: {
|
||||||
feed: string
|
feed: string;
|
||||||
calendar: string
|
calendar: string;
|
||||||
map: string
|
map: string;
|
||||||
favorites: string
|
favorites: string;
|
||||||
settings: string
|
settings: string;
|
||||||
}
|
};
|
||||||
search: {
|
search: {
|
||||||
placeholder: string
|
placeholder: string;
|
||||||
noResults: string
|
noResults: string;
|
||||||
}
|
};
|
||||||
favorites: {
|
favorites: {
|
||||||
title: string
|
title: string;
|
||||||
loginPrompt: string
|
loginPrompt: string;
|
||||||
empty: string
|
empty: string;
|
||||||
emptyHint: string
|
emptyHint: string;
|
||||||
logIn: string
|
logIn: string;
|
||||||
}
|
};
|
||||||
settings: {
|
settings: {
|
||||||
title: string
|
title: string;
|
||||||
account: string
|
account: string;
|
||||||
loginPrompt: string
|
loginPrompt: string;
|
||||||
logIn: string
|
logIn: string;
|
||||||
logOut: string
|
logOut: string;
|
||||||
appearance: string
|
appearance: string;
|
||||||
theme: string
|
theme: string;
|
||||||
language: string
|
language: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
// Libra accounting module
|
// Libra accounting module
|
||||||
libra?: {
|
libra?: {
|
||||||
nav: {
|
nav: {
|
||||||
record: string
|
record: string;
|
||||||
transactions: string
|
transactions: string;
|
||||||
balance: string
|
balance: string;
|
||||||
wallet: string
|
wallet: string;
|
||||||
settings: string
|
settings: string;
|
||||||
}
|
};
|
||||||
record: {
|
record: {
|
||||||
title: string
|
title: string;
|
||||||
addExpense: string
|
addExpense: string;
|
||||||
addExpenseDescription: string
|
addExpenseDescription: string;
|
||||||
addIncome: string
|
addIncome: string;
|
||||||
addIncomeDescription: string
|
addIncomeDescription: string;
|
||||||
comingSoon: string
|
comingSoon: string;
|
||||||
drafts: string
|
drafts: string;
|
||||||
noDrafts: string
|
noDrafts: string;
|
||||||
draftAge: string
|
draftAge: string;
|
||||||
saveDraft: string
|
saveDraft: string;
|
||||||
deleteDraft: string
|
deleteDraft: string;
|
||||||
}
|
};
|
||||||
balance: {
|
balance: {
|
||||||
title: string
|
title: string;
|
||||||
netBalance: string
|
netBalance: string;
|
||||||
pending: string
|
pending: string;
|
||||||
pendingExpenses: string
|
pendingExpenses: string;
|
||||||
pendingAmount: string
|
pendingAmount: string;
|
||||||
noBalance: string
|
noBalance: string;
|
||||||
owedToYou: string
|
owedToYou: string;
|
||||||
youOwe: string
|
youOwe: string;
|
||||||
}
|
};
|
||||||
settings: {
|
settings: {
|
||||||
title: string
|
title: string;
|
||||||
account: string
|
account: string;
|
||||||
loginPrompt: string
|
loginPrompt: string;
|
||||||
logIn: string
|
logIn: string;
|
||||||
logOut: string
|
logOut: string;
|
||||||
appearance: string
|
appearance: string;
|
||||||
theme: string
|
theme: string;
|
||||||
language: string
|
language: string;
|
||||||
}
|
};
|
||||||
income: {
|
income: {
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
selectAccount: string
|
selectAccount: string;
|
||||||
otherAccountHint: string
|
otherAccountHint: string;
|
||||||
submitIncome: string
|
submitIncome: string;
|
||||||
notAvailable: string
|
notAvailable: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
// Market module
|
// Market module
|
||||||
market?: {
|
market?: {
|
||||||
auth: {
|
auth: {
|
||||||
loginPrompt: string
|
loginPrompt: string;
|
||||||
logIn: string
|
logIn: string;
|
||||||
logInToCheckout: string
|
logInToCheckout: string;
|
||||||
nostrKeyRequired: string
|
nostrKeyRequired: string;
|
||||||
nostrKeyDescription: string
|
nostrKeyDescription: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
// Add date/time formats
|
// Add date/time formats
|
||||||
dateTimeFormats: {
|
dateTimeFormats: {
|
||||||
short: {
|
short: {
|
||||||
year: 'numeric'
|
year: "numeric";
|
||||||
month: 'short'
|
month: "short";
|
||||||
day: 'numeric'
|
day: "numeric";
|
||||||
}
|
};
|
||||||
long: {
|
long: {
|
||||||
year: 'numeric'
|
year: "numeric";
|
||||||
month: 'long'
|
month: "long";
|
||||||
day: 'numeric'
|
day: "numeric";
|
||||||
weekday: 'long'
|
weekday: "long";
|
||||||
hour: 'numeric'
|
hour: "numeric";
|
||||||
minute: 'numeric'
|
minute: "numeric";
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
// Add number formats
|
// Add number formats
|
||||||
numberFormats: {
|
numberFormats: {
|
||||||
currency: {
|
currency: {
|
||||||
style: 'currency'
|
style: "currency";
|
||||||
currencyDisplay: 'symbol'
|
currencyDisplay: "symbol";
|
||||||
}
|
};
|
||||||
decimal: {
|
decimal: {
|
||||||
style: 'decimal'
|
style: "decimal";
|
||||||
minimumFractionDigits: 2
|
minimumFractionDigits: 2;
|
||||||
}
|
};
|
||||||
percent: {
|
percent: {
|
||||||
style: 'percent'
|
style: "percent";
|
||||||
useGrouping: false
|
useGrouping: false;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
195
src/modules/events/components/TicketWavesEditor.vue
Normal file
195
src/modules/events/components/TicketWavesEditor.vue
Normal 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>
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
import {
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
computed,
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
ref,
|
||||||
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes'
|
toValue,
|
||||||
|
watch,
|
||||||
|
type MaybeRefOrGetter,
|
||||||
|
type Ref,
|
||||||
|
} from "vue";
|
||||||
|
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
|
||||||
|
import type { TicketApiService } from "../services/TicketApiService";
|
||||||
|
import {
|
||||||
|
isUnsupportedEndpointError,
|
||||||
|
normalizePromoCode,
|
||||||
|
} from "../lib/promoCodes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buyer-side promo state for the purchase dialog.
|
* Buyer-side promo state for the purchase dialog.
|
||||||
|
|
@ -17,111 +27,138 @@ import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCode
|
||||||
* every extension version.
|
* every extension version.
|
||||||
*/
|
*/
|
||||||
export type PromoState =
|
export type PromoState =
|
||||||
| { status: 'idle' }
|
| { status: "idle" }
|
||||||
| { status: 'checking'; code: string }
|
| { status: "checking"; code: string }
|
||||||
| {
|
| {
|
||||||
status: 'applied'
|
status: "applied";
|
||||||
code: string
|
code: string;
|
||||||
discountPercent: number
|
discountPercent: number;
|
||||||
subtotal: number
|
subtotal: number;
|
||||||
discount: number
|
discount: number;
|
||||||
total: number
|
total: number;
|
||||||
currency: string
|
currency: string;
|
||||||
}
|
}
|
||||||
| { status: 'invalid'; code: string }
|
| { status: "invalid"; code: string }
|
||||||
| { status: 'unverified'; code: string }
|
| { status: "unverified"; code: string };
|
||||||
|
|
||||||
const REVALIDATE_DEBOUNCE_MS = 300
|
const REVALIDATE_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export function usePromoCode(eventId: MaybeRefOrGetter<string>, quantity: Ref<number>) {
|
export function usePromoCode(
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
eventId: MaybeRefOrGetter<string>,
|
||||||
|
quantity: Ref<number>,
|
||||||
|
ticketWaveId?: MaybeRefOrGetter<string | undefined>,
|
||||||
|
) {
|
||||||
|
const ticketApi = injectService(
|
||||||
|
SERVICE_TOKENS.TICKET_API,
|
||||||
|
) as TicketApiService;
|
||||||
|
|
||||||
/** What the buyer typed (kept upper-case by the input handler). */
|
/** What the buyer typed (kept upper-case by the input handler). */
|
||||||
const input = ref('')
|
const input = ref("");
|
||||||
const state = ref<PromoState>({ status: 'idle' })
|
const state = ref<PromoState>({ status: "idle" });
|
||||||
|
|
||||||
// Monotonic request counter so a slow response for a previous code or
|
// Monotonic request counter so a slow response for a previous code or
|
||||||
// quantity can never overwrite a newer result.
|
// quantity can never overwrite a newer result.
|
||||||
let requestSeq = 0
|
let requestSeq = 0;
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const isChecking = computed(() => state.value.status === 'checking')
|
const isChecking = computed(() => state.value.status === "checking");
|
||||||
|
|
||||||
/** Code to put on the purchase request. `invalid` deliberately sends
|
/** Code to put on the purchase request. `invalid` deliberately sends
|
||||||
* nothing so the buyer is not surprised by a 400 at pay time. */
|
* nothing so the buyer is not surprised by a 400 at pay time. */
|
||||||
const codeToSend = computed<string | undefined>(() => {
|
const codeToSend = computed<string | undefined>(() => {
|
||||||
const s = state.value
|
const s = state.value;
|
||||||
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined
|
return s.status === "applied" || s.status === "unverified"
|
||||||
})
|
? s.code
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
const appliedTotal = computed<number | null>(() =>
|
const appliedTotal = computed<number | null>(() =>
|
||||||
state.value.status === 'applied' ? state.value.total : null,
|
state.value.status === "applied" ? state.value.total : null,
|
||||||
)
|
);
|
||||||
|
|
||||||
async function validate(code: string): Promise<void> {
|
async function validate(code: string): Promise<void> {
|
||||||
const seq = ++requestSeq
|
const seq = ++requestSeq;
|
||||||
state.value = { status: 'checking', code }
|
state.value = { status: "checking", code };
|
||||||
try {
|
try {
|
||||||
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value)
|
const totals = await ticketApi.validatePromoCode(
|
||||||
if (seq !== requestSeq) return
|
toValue(eventId),
|
||||||
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0]
|
code,
|
||||||
|
quantity.value,
|
||||||
|
toValue(ticketWaveId),
|
||||||
|
);
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
|
const hit =
|
||||||
|
totals.discounts_applied.find((d) => d.code === code) ??
|
||||||
|
totals.discounts_applied[0];
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
state.value = { status: 'invalid', code }
|
state.value = { status: "invalid", code };
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
state.value = {
|
state.value = {
|
||||||
status: 'applied',
|
status: "applied",
|
||||||
code: hit.code,
|
code: hit.code,
|
||||||
discountPercent: hit.discount_percent ?? 0,
|
discountPercent: hit.discount_percent ?? 0,
|
||||||
subtotal: totals.subtotal,
|
subtotal: totals.subtotal,
|
||||||
discount: totals.discount,
|
discount: totals.discount,
|
||||||
total: totals.total,
|
total: totals.total,
|
||||||
currency: totals.currency,
|
currency: totals.currency,
|
||||||
}
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (seq !== requestSeq) return
|
if (seq !== requestSeq) return;
|
||||||
if (isUnsupportedEndpointError(err)) {
|
if (isUnsupportedEndpointError(err)) {
|
||||||
state.value = { status: 'unverified', code }
|
state.value = { status: "unverified", code };
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
// Network / 5xx: don't block the purchase on an advisory call.
|
// Network / 5xx: don't block the purchase on an advisory call.
|
||||||
console.warn('[usePromoCode] validate failed, sending code unverified:', err)
|
console.warn(
|
||||||
state.value = { status: 'unverified', code }
|
"[usePromoCode] validate failed, sending code unverified:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
state.value = { status: "unverified", code };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apply(): Promise<void> {
|
async function apply(): Promise<void> {
|
||||||
const code = normalizePromoCode(input.value)
|
const code = normalizePromoCode(input.value);
|
||||||
if (!code) return
|
if (!code) return;
|
||||||
input.value = code
|
input.value = code;
|
||||||
await validate(code)
|
await validate(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear(): void {
|
function clear(): void {
|
||||||
requestSeq++
|
requestSeq++;
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
debounceTimer = null
|
debounceTimer = null;
|
||||||
input.value = ''
|
input.value = "";
|
||||||
state.value = { status: 'idle' }
|
state.value = { status: "idle" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-check the current code (quantity changed, or the purchase
|
/** Re-check the current code (quantity changed, or the purchase
|
||||||
* endpoint rejected it — e.g. the last use went to someone else). */
|
* endpoint rejected it — e.g. the last use went to someone else). */
|
||||||
async function revalidate(): Promise<void> {
|
async function revalidate(): Promise<void> {
|
||||||
const s = state.value
|
const s = state.value;
|
||||||
if (s.status === 'idle' || s.status === 'checking') return
|
if (s.status === "idle" || s.status === "checking") return;
|
||||||
await validate(s.code)
|
await validate(s.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(quantity, () => {
|
watch(quantity, () => {
|
||||||
const s = state.value
|
const s = state.value;
|
||||||
if (s.status === 'idle') return
|
if (s.status === "idle") return;
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
debounceTimer = null
|
debounceTimer = null;
|
||||||
void revalidate()
|
void revalidate();
|
||||||
}, REVALIDATE_DEBOUNCE_MS)
|
}, REVALIDATE_DEBOUNCE_MS);
|
||||||
})
|
});
|
||||||
|
|
||||||
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate }
|
return {
|
||||||
|
input,
|
||||||
|
state,
|
||||||
|
isChecking,
|
||||||
|
codeToSend,
|
||||||
|
appliedTotal,
|
||||||
|
apply,
|
||||||
|
clear,
|
||||||
|
revalidate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,56 @@
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from "vue";
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from "@/composables/useAuthService";
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from "vue-sonner";
|
||||||
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
import { useAsyncOperation } from "@/core/composables/useAsyncOperation";
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
|
||||||
import type { PaymentService } from '@/core/services/PaymentService'
|
import type { PaymentService } from "@/core/services/PaymentService";
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from "../services/TicketApiService";
|
||||||
import { useOwnedTickets } from './useOwnedTickets'
|
import { useOwnedTickets } from "./useOwnedTickets";
|
||||||
import { frontendRoot } from '../lib/frontendRoot'
|
import { frontendRoot } from "../lib/frontendRoot";
|
||||||
import { renderTicketQr } from '../lib/ticketQr'
|
import { renderTicketQr } from "../lib/ticketQr";
|
||||||
|
|
||||||
/** Guest identity for a purchase without an LNbits account. */
|
/** Guest identity for a purchase without an LNbits account. */
|
||||||
export interface PurchaseContact {
|
export interface PurchaseContact {
|
||||||
name: string
|
name: string;
|
||||||
email: string
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseTicketOptions {
|
export interface PurchaseTicketOptions {
|
||||||
quantity?: number
|
quantity?: number;
|
||||||
/** Guest buyer (no account). Required when not authenticated. */
|
/** Guest buyer (no account). Required when not authenticated. */
|
||||||
contact?: PurchaseContact
|
contact?: PurchaseContact;
|
||||||
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
|
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
|
||||||
email?: string
|
email?: string;
|
||||||
/** Promo code to redeem (already normalised by the dialog). */
|
/** Promo code to redeem (already normalised by the dialog). */
|
||||||
promoCode?: string
|
promoCode?: string;
|
||||||
|
/** Pricing tier the buyer chose. Required when several waves are open;
|
||||||
|
* the backend refuses to guess. */
|
||||||
|
ticketWaveId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTicketPurchase() {
|
export function useTicketPurchase() {
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth();
|
||||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
|
const paymentService = injectService(
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
SERVICE_TOKENS.PAYMENT_SERVICE,
|
||||||
|
) as PaymentService;
|
||||||
|
const ticketApi = injectService(
|
||||||
|
SERVICE_TOKENS.TICKET_API,
|
||||||
|
) as TicketApiService;
|
||||||
|
|
||||||
// Refresh the shared owned-tickets singleton after a purchase so the
|
// Refresh the shared owned-tickets singleton after a purchase so the
|
||||||
// feed/calendar "My tickets" filter and EventCard owned badges update
|
// feed/calendar "My tickets" filter and EventCard owned badges update
|
||||||
// without a reload — purchase is exactly the "consumer that mutates
|
// without a reload — purchase is exactly the "consumer that mutates
|
||||||
// the ticket set" useOwnedTickets's docs anticipate.
|
// the ticket set" useOwnedTickets's docs anticipate.
|
||||||
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
const { refresh: refreshOwnedTickets } = useOwnedTickets();
|
||||||
|
|
||||||
// Async operations
|
// Async operations
|
||||||
const purchaseOperation = useAsyncOperation()
|
const purchaseOperation = useAsyncOperation();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const paymentHash = ref<string | null>(null)
|
const paymentHash = ref<string | null>(null);
|
||||||
const paymentRequest = ref<string | null>(null)
|
const paymentRequest = ref<string | null>(null);
|
||||||
const qrCode = ref<string | null>(null)
|
const qrCode = ref<string | null>(null);
|
||||||
const isPaymentPending = ref(false)
|
const isPaymentPending = ref(false);
|
||||||
|
|
||||||
// Ticket QR code state. After payment lands, `purchasedTicketIds`
|
// Ticket QR code state. After payment lands, `purchasedTicketIds`
|
||||||
// is populated with every row id created on the invoice (one for
|
// is populated with every row id created on the invoice (one for
|
||||||
|
|
@ -51,62 +58,68 @@ export function useTicketPurchase() {
|
||||||
// parallel map id → QR data URL so the UI can render one QR per
|
// parallel map id → QR data URL so the UI can render one QR per
|
||||||
// attendee. `purchasedTicketId` stays for back-compat with the
|
// attendee. `purchasedTicketId` stays for back-compat with the
|
||||||
// single-id success path.
|
// single-id success path.
|
||||||
const ticketQRCode = ref<string | null>(null)
|
const ticketQRCode = ref<string | null>(null);
|
||||||
const ticketQRCodes = ref<Record<string, string>>({})
|
const ticketQRCodes = ref<Record<string, string>>({});
|
||||||
const purchasedTicketId = ref<string | null>(null)
|
const purchasedTicketId = ref<string | null>(null);
|
||||||
const purchasedTicketIds = ref<string[]>([])
|
const purchasedTicketIds = ref<string[]>([]);
|
||||||
const showTicketQR = ref(false)
|
const showTicketQR = ref(false);
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
|
const canPurchase = computed(
|
||||||
|
() => isAuthenticated.value && currentUser.value,
|
||||||
|
);
|
||||||
const userDisplay = computed(() => {
|
const userDisplay = computed(() => {
|
||||||
if (!currentUser.value) return null
|
if (!currentUser.value) return null;
|
||||||
return {
|
return {
|
||||||
name: currentUser.value.username || currentUser.value.id,
|
name: currentUser.value.username || currentUser.value.id,
|
||||||
shortId: currentUser.value.id.slice(0, 8)
|
shortId: currentUser.value.id.slice(0, 8),
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
// Delegate to PaymentService
|
// Delegate to PaymentService
|
||||||
const userWallets = computed(() => paymentService.userWallets)
|
const userWallets = computed(() => paymentService.userWallets);
|
||||||
const hasWalletWithBalance = computed(() => paymentService.hasWalletWithBalance)
|
const hasWalletWithBalance = computed(
|
||||||
const isPayingWithWallet = computed(() => paymentService.isProcessingPayment.value)
|
() => paymentService.hasWalletWithBalance,
|
||||||
|
);
|
||||||
|
const isPayingWithWallet = computed(
|
||||||
|
() => paymentService.isProcessingPayment.value,
|
||||||
|
);
|
||||||
|
|
||||||
async function generateQRCode(bolt11: string) {
|
async function generateQRCode(bolt11: string) {
|
||||||
try {
|
try {
|
||||||
qrCode.value = await paymentService.generateQRCode(bolt11)
|
qrCode.value = await paymentService.generateQRCode(bolt11);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error generating QR code:', err)
|
console.error("Error generating QR code:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateTicketQRCode(ticketId: string) {
|
async function generateTicketQRCode(ticketId: string) {
|
||||||
try {
|
try {
|
||||||
const dataUrl = await renderTicketQr(ticketId, { width: 160 })
|
const dataUrl = await renderTicketQr(ticketId, { width: 160 });
|
||||||
ticketQRCode.value = dataUrl
|
ticketQRCode.value = dataUrl;
|
||||||
return dataUrl
|
return dataUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating ticket QR code:', error)
|
console.error("Error generating ticket QR code:", error);
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function payWithWallet(pr: string) {
|
async function payWithWallet(pr: string) {
|
||||||
try {
|
try {
|
||||||
await paymentService.payWithWallet(pr, undefined, {
|
await paymentService.payWithWallet(pr, undefined, {
|
||||||
showToast: false
|
showToast: false,
|
||||||
})
|
});
|
||||||
return true
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Wallet payment failed:', error)
|
console.error("Wallet payment failed:", error);
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The event id this composable is currently driving — kept so
|
/** The event id this composable is currently driving — kept so
|
||||||
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
|
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
|
||||||
* have to take it as an argument from the UI. */
|
* have to take it as an argument from the UI. */
|
||||||
const currentEventId = ref<string | null>(null)
|
const currentEventId = ref<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance to the ticket-QR success state for the given row ids. Shared
|
* Advance to the ticket-QR success state for the given row ids. Shared
|
||||||
|
|
@ -118,26 +131,26 @@ export function useTicketPurchase() {
|
||||||
// Ticket row(s) now exist — refresh the shared owned-tickets state so
|
// Ticket row(s) now exist — refresh the shared owned-tickets state so
|
||||||
// the feed/calendar My-tickets filter and owned badges reflect the
|
// the feed/calendar My-tickets filter and owned badges reflect the
|
||||||
// purchase immediately (no reload). Runs in parallel with QR gen.
|
// purchase immediately (no reload). Runs in parallel with QR gen.
|
||||||
void refreshOwnedTickets()
|
void refreshOwnedTickets();
|
||||||
|
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
purchasedTicketIds.value = ids
|
purchasedTicketIds.value = ids;
|
||||||
purchasedTicketId.value = ids[0]
|
purchasedTicketId.value = ids[0];
|
||||||
const qrMap: Record<string, string> = {}
|
const qrMap: Record<string, string> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const dataUrl = await generateTicketQRCode(id)
|
const dataUrl = await generateTicketQRCode(id);
|
||||||
if (dataUrl) qrMap[id] = dataUrl
|
if (dataUrl) qrMap[id] = dataUrl;
|
||||||
}
|
}
|
||||||
ticketQRCodes.value = qrMap
|
ticketQRCodes.value = qrMap;
|
||||||
ticketQRCode.value = qrMap[ids[0]] ?? null
|
ticketQRCode.value = qrMap[ids[0]] ?? null;
|
||||||
showTicketQR.value = true
|
showTicketQR.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
ids.length > 1
|
ids.length > 1
|
||||||
? `${ids.length} tickets purchased!`
|
? `${ids.length} tickets purchased!`
|
||||||
: 'Ticket purchased successfully!',
|
: "Ticket purchased successfully!",
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purchaseTicketForEvent(
|
async function purchaseTicketForEvent(
|
||||||
|
|
@ -147,26 +160,27 @@ export function useTicketPurchase() {
|
||||||
// Guests are identified by name + email; logged-in buyers by user id.
|
// Guests are identified by name + email; logged-in buyers by user id.
|
||||||
// Either is enough for a paid ticket — free tickets stay login-gated
|
// Either is enough for a paid ticket — free tickets stay login-gated
|
||||||
// in the dialog (events#29: no anonymous free issuance).
|
// in the dialog (events#29: no anonymous free issuance).
|
||||||
const isGuest = !canPurchase.value || !currentUser.value
|
const isGuest = !canPurchase.value || !currentUser.value;
|
||||||
if (isGuest && !options.contact) {
|
if (isGuest && !options.contact) {
|
||||||
throw new Error('User must be authenticated to purchase tickets')
|
throw new Error("User must be authenticated to purchase tickets");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await purchaseOperation.execute(async () => {
|
return await purchaseOperation.execute(
|
||||||
|
async () => {
|
||||||
// Clear previous state
|
// Clear previous state
|
||||||
paymentHash.value = null
|
paymentHash.value = null;
|
||||||
paymentRequest.value = null
|
paymentRequest.value = null;
|
||||||
qrCode.value = null
|
qrCode.value = null;
|
||||||
ticketQRCode.value = null
|
ticketQRCode.value = null;
|
||||||
ticketQRCodes.value = {}
|
ticketQRCodes.value = {};
|
||||||
purchasedTicketId.value = null
|
purchasedTicketId.value = null;
|
||||||
purchasedTicketIds.value = []
|
purchasedTicketIds.value = [];
|
||||||
showTicketQR.value = false
|
showTicketQR.value = false;
|
||||||
currentEventId.value = eventId
|
currentEventId.value = eventId;
|
||||||
|
|
||||||
// Get the invoice via TicketApiService
|
// Get the invoice via TicketApiService
|
||||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any;
|
||||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
const accessToken = lnbitsAPI?.getAccessToken?.() || "";
|
||||||
|
|
||||||
const invoice = await ticketApi.requestTicket(eventId, {
|
const invoice = await ticketApi.requestTicket(eventId, {
|
||||||
userId: isGuest ? undefined : currentUser.value!.id,
|
userId: isGuest ? undefined : currentUser.value!.id,
|
||||||
|
|
@ -176,16 +190,17 @@ export function useTicketPurchase() {
|
||||||
frontendUrl: frontendRoot(),
|
frontendUrl: frontendRoot(),
|
||||||
quantity: options.quantity,
|
quantity: options.quantity,
|
||||||
promoCode: options.promoCode,
|
promoCode: options.promoCode,
|
||||||
})
|
ticketWaveId: options.ticketWaveId,
|
||||||
|
});
|
||||||
|
|
||||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||||
// issued + marked them paid, so there's no invoice to settle. Skip
|
// issued + marked them paid, so there's no invoice to settle. Skip
|
||||||
// the QR / payment-poll and jump straight to the ticket-QR success
|
// the QR / payment-poll and jump straight to the ticket-QR success
|
||||||
// state with the ids returned inline.
|
// state with the ids returned inline.
|
||||||
if (invoice.paid) {
|
if (invoice.paid) {
|
||||||
paymentHash.value = invoice.paymentHash
|
paymentHash.value = invoice.paymentHash;
|
||||||
await finalizePurchasedTickets(invoice.ticketIds ?? [])
|
await finalizePurchasedTickets(invoice.ticketIds ?? []);
|
||||||
return invoice
|
return invoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise the backend returns either a Lightning invoice or a fiat
|
// Otherwise the backend returns either a Lightning invoice or a fiat
|
||||||
|
|
@ -197,28 +212,30 @@ export function useTicketPurchase() {
|
||||||
// broken QR.
|
// broken QR.
|
||||||
if (invoice.isFiat || !invoice.paymentRequest) {
|
if (invoice.isFiat || !invoice.paymentRequest) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'This event uses fiat checkout. Use the purchase dialog ' +
|
"This event uses fiat checkout. Use the purchase dialog " +
|
||||||
'to follow the provider link.',
|
"to follow the provider link.",
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
const bolt11: string = invoice.paymentRequest
|
const bolt11: string = invoice.paymentRequest;
|
||||||
paymentHash.value = invoice.paymentHash
|
paymentHash.value = invoice.paymentHash;
|
||||||
paymentRequest.value = bolt11
|
paymentRequest.value = bolt11;
|
||||||
|
|
||||||
// Generate QR code for payment
|
// Generate QR code for payment
|
||||||
await generateQRCode(bolt11)
|
await generateQRCode(bolt11);
|
||||||
|
|
||||||
// Restaurant-style: don't auto-pay. Surface the QR + amount and
|
// Restaurant-style: don't auto-pay. Surface the QR + amount and
|
||||||
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
|
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
|
||||||
// external wallet" on the same screen. The composable just
|
// external wallet" on the same screen. The composable just
|
||||||
// starts polling so when payment lands (from any path) the UI
|
// starts polling so when payment lands (from any path) the UI
|
||||||
// advances to the ticket-QR success state.
|
// advances to the ticket-QR success state.
|
||||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
await startPaymentStatusCheck(eventId, invoice.paymentHash);
|
||||||
|
|
||||||
return invoice
|
return invoice;
|
||||||
}, {
|
},
|
||||||
errorMessage: 'Failed to purchase ticket'
|
{
|
||||||
})
|
errorMessage: "Failed to purchase ticket",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -227,76 +244,77 @@ export function useTicketPurchase() {
|
||||||
* my LNbits wallet" button on the invoice screen.
|
* my LNbits wallet" button on the invoice screen.
|
||||||
*/
|
*/
|
||||||
async function payCurrentInvoiceWithWallet(): Promise<void> {
|
async function payCurrentInvoiceWithWallet(): Promise<void> {
|
||||||
if (!paymentRequest.value) return
|
if (!paymentRequest.value) return;
|
||||||
await payWithWallet(paymentRequest.value)
|
await payWithWallet(paymentRequest.value);
|
||||||
// Polling is already running from purchaseTicketForEvent — when
|
// Polling is already running from purchaseTicketForEvent — when
|
||||||
// the payment lands, it advances to showTicketQR. No need to
|
// the payment lands, it advances to showTicketQR. No need to
|
||||||
// restart it here.
|
// restart it here.
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startPaymentStatusCheck(eventId: string, hash: string) {
|
async function startPaymentStatusCheck(eventId: string, hash: string) {
|
||||||
isPaymentPending.value = true
|
isPaymentPending.value = true;
|
||||||
let checkInterval: number | null = null
|
let checkInterval: number | null = null;
|
||||||
|
|
||||||
const checkPayment = async () => {
|
const checkPayment = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await ticketApi.checkPaymentStatus(eventId, hash)
|
const result = await ticketApi.checkPaymentStatus(eventId, hash);
|
||||||
|
|
||||||
if (result.paid) {
|
if (result.paid) {
|
||||||
isPaymentPending.value = false
|
isPaymentPending.value = false;
|
||||||
if (checkInterval) {
|
if (checkInterval) {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-ticket purchases come back with `ticketIds` (N rows
|
// Multi-ticket purchases come back with `ticketIds` (N rows
|
||||||
// sharing one invoice). Single-ticket purchases include
|
// sharing one invoice). Single-ticket purchases include
|
||||||
// `ticketId` only.
|
// `ticketId` only.
|
||||||
const ids = result.ticketIds && result.ticketIds.length > 0
|
const ids =
|
||||||
|
result.ticketIds && result.ticketIds.length > 0
|
||||||
? result.ticketIds
|
? result.ticketIds
|
||||||
: result.ticketId
|
: result.ticketId
|
||||||
? [result.ticketId]
|
? [result.ticketId]
|
||||||
: []
|
: [];
|
||||||
await finalizePurchasedTickets(ids)
|
await finalizePurchasedTickets(ids);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error checking payment status:', err)
|
console.error("Error checking payment status:", err);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
await checkPayment()
|
await checkPayment();
|
||||||
checkInterval = setInterval(checkPayment, 2000) as unknown as number
|
checkInterval = setInterval(checkPayment, 2000) as unknown as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPaymentStatusCheck() {
|
function stopPaymentStatusCheck() {
|
||||||
isPaymentPending.value = false
|
isPaymentPending.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPaymentState() {
|
function resetPaymentState() {
|
||||||
purchaseOperation.clear()
|
purchaseOperation.clear();
|
||||||
paymentHash.value = null
|
paymentHash.value = null;
|
||||||
paymentRequest.value = null
|
paymentRequest.value = null;
|
||||||
qrCode.value = null
|
qrCode.value = null;
|
||||||
isPaymentPending.value = false
|
isPaymentPending.value = false;
|
||||||
ticketQRCode.value = null
|
ticketQRCode.value = null;
|
||||||
ticketQRCodes.value = {}
|
ticketQRCodes.value = {};
|
||||||
purchasedTicketId.value = null
|
purchasedTicketId.value = null;
|
||||||
purchasedTicketIds.value = []
|
purchasedTicketIds.value = [];
|
||||||
showTicketQR.value = false
|
showTicketQR.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenLightningWallet() {
|
function handleOpenLightningWallet() {
|
||||||
if (paymentRequest.value) {
|
if (paymentRequest.value) {
|
||||||
paymentService.openExternalWallet(paymentRequest.value)
|
paymentService.openExternalWallet(paymentRequest.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
stopPaymentStatusCheck()
|
stopPaymentStatusCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cleanup()
|
cleanup();
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
|
|
@ -329,6 +347,6 @@ export function useTicketPurchase() {
|
||||||
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
|
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
|
||||||
// (backend returns `paid: true`, no checkout URL) on the same
|
// (backend returns `paid: true`, no checkout URL) on the same
|
||||||
// success screen as the Lightning / free paths.
|
// success screen as the Lightning / free paths.
|
||||||
finalizePurchasedTickets
|
finalizePurchasedTickets,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
329
src/modules/events/lib/ticketWaves.spec.ts
Normal file
329
src/modules/events/lib/ticketWaves.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
294
src/modules/events/lib/ticketWaves.ts
Normal file
294
src/modules/events/lib/ticketWaves.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,146 +1,300 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { TicketApiService } from './TicketApiService'
|
import { TicketApiService } from "./TicketApiService";
|
||||||
|
|
||||||
const BASE = 'https://lnbits.test'
|
const BASE = "https://lnbits.test";
|
||||||
|
|
||||||
function okJson(body: unknown) {
|
function okJson(body: unknown) {
|
||||||
return { ok: true, json: async () => body } as Response
|
return { ok: true, json: async () => body } as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TicketApiService.requestTicket', () => {
|
describe("TicketApiService.requestTicket", () => {
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn();
|
||||||
let api: TicketApiService
|
let api: TicketApiService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset()
|
fetchMock.mockReset();
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
|
||||||
})
|
});
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
function lastCall() {
|
function lastCall() {
|
||||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
|
return {
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
body: JSON.parse(init.body as string),
|
||||||
|
headers: init.headers as Record<string, string>,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('sends name + email and no user_id / Authorization for guests', async () => {
|
it("sends name + email and no user_id / Authorization for guests", async () => {
|
||||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] }))
|
fetchMock.mockResolvedValue(
|
||||||
const res = await api.requestTicket('evt', {
|
okJson({
|
||||||
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events',
|
payment_hash: "h",
|
||||||
paymentMethod: 'fiat', quantity: 2,
|
fiat_payment_request: "https://checkout",
|
||||||
})
|
is_fiat: true,
|
||||||
const { url, body, headers } = lastCall()
|
ticket_ids: ["a", "b"],
|
||||||
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`)
|
}),
|
||||||
|
);
|
||||||
|
const res = await api.requestTicket("evt", {
|
||||||
|
name: "Guest",
|
||||||
|
email: "g@example.com",
|
||||||
|
frontendUrl: "https://demo.aiolabs.dev/events",
|
||||||
|
paymentMethod: "fiat",
|
||||||
|
quantity: 2,
|
||||||
|
});
|
||||||
|
const { url, body, headers } = lastCall();
|
||||||
|
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`);
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
|
name: "Guest",
|
||||||
payment_method: 'fiat', quantity: 2,
|
email: "g@example.com",
|
||||||
})
|
frontend_url: "https://demo.aiolabs.dev/events",
|
||||||
expect(body.user_id).toBeUndefined()
|
payment_method: "fiat",
|
||||||
expect(headers.Authorization).toBeUndefined()
|
quantity: 2,
|
||||||
expect(headers['X-API-KEY']).toBe('inst-key')
|
});
|
||||||
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] })
|
expect(body.user_id).toBeUndefined();
|
||||||
})
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
expect(headers["X-API-KEY"]).toBe("inst-key");
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
isFiat: true,
|
||||||
|
fiatPaymentRequest: "https://checkout",
|
||||||
|
ticketIds: ["a", "b"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('sends user_id + email with a Bearer token for logged-in buyers', async () => {
|
it("sends user_id + email with a Bearer token for logged-in buyers", async () => {
|
||||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
fetchMock.mockResolvedValue(
|
||||||
await api.requestTicket('evt', {
|
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
|
||||||
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173',
|
);
|
||||||
})
|
await api.requestTicket("evt", {
|
||||||
const { body, headers } = lastCall()
|
userId: "u1",
|
||||||
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' })
|
email: "me@example.com",
|
||||||
expect(headers.Authorization).toBe('Bearer tok')
|
accessToken: "tok",
|
||||||
})
|
frontendUrl: "http://localhost:5173",
|
||||||
|
});
|
||||||
|
const { body, headers } = lastCall();
|
||||||
|
expect(body).toEqual({
|
||||||
|
user_id: "u1",
|
||||||
|
email: "me@example.com",
|
||||||
|
frontend_url: "http://localhost:5173",
|
||||||
|
});
|
||||||
|
expect(headers.Authorization).toBe("Bearer tok");
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces the backend detail string as the error message', async () => {
|
it("surfaces the backend detail string as the error message", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response)
|
fetchMock.mockResolvedValue({
|
||||||
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' }))
|
ok: false,
|
||||||
.rejects.toThrow('Payment method not enabled for this event.')
|
json: async () => ({
|
||||||
})
|
detail: "Payment method not enabled for this event.",
|
||||||
})
|
}),
|
||||||
|
} as Response);
|
||||||
|
await expect(
|
||||||
|
api.requestTicket("evt", {
|
||||||
|
name: "G",
|
||||||
|
email: "g@x.io",
|
||||||
|
paymentMethod: "fiat",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Payment method not enabled for this event.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('TicketApiService.getTicket / resendTicketEmail', () => {
|
describe("TicketApiService.getTicket / resendTicketEmail", () => {
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn();
|
||||||
let api: TicketApiService
|
let api: TicketApiService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset()
|
fetchMock.mockReset();
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
|
api = new TicketApiService({ baseUrl: BASE, apiKey: "k" });
|
||||||
})
|
});
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
it('maps the anonymous PublicTicket shape', async () => {
|
it("maps the anonymous PublicTicket shape", async () => {
|
||||||
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' }))
|
fetchMock.mockResolvedValue(
|
||||||
const t = await api.getTicket('tid')
|
okJson({
|
||||||
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`)
|
event: "evt",
|
||||||
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' })
|
name: "G",
|
||||||
})
|
registered: false,
|
||||||
|
paid: true,
|
||||||
|
time: "t",
|
||||||
|
reg_timestamp: "r",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const t = await api.getTicket("tid");
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||||
|
`${BASE}/events/api/v1/tickets/tid`,
|
||||||
|
);
|
||||||
|
expect(t).toEqual({
|
||||||
|
eventId: "evt",
|
||||||
|
name: "G",
|
||||||
|
registered: false,
|
||||||
|
paid: true,
|
||||||
|
time: "t",
|
||||||
|
regTimestamp: "r",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => {
|
it("maps the structured resend result and tolerates the legacy bare-ticket shape", async () => {
|
||||||
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} }
|
const ticket = {
|
||||||
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } }))
|
id: "tid",
|
||||||
const r = await api.resendTicketEmail('tid', 'admin')
|
wallet: "w",
|
||||||
expect(r.ticket.id).toBe('tid')
|
event: "evt",
|
||||||
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' })
|
name: "G",
|
||||||
expect(r.nostr.attempted).toBe(false)
|
email: "g@x.io",
|
||||||
|
user_id: null,
|
||||||
|
registered: false,
|
||||||
|
paid: true,
|
||||||
|
time: "t",
|
||||||
|
reg_timestamp: "r",
|
||||||
|
extra: {},
|
||||||
|
};
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
okJson({
|
||||||
|
ticket,
|
||||||
|
email: { attempted: true, sent: false, error: "smtp down" },
|
||||||
|
nostr: { attempted: false, sent: false, error: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const r = await api.resendTicketEmail("tid", "admin");
|
||||||
|
expect(r.ticket.id).toBe("tid");
|
||||||
|
expect(r.email).toEqual({
|
||||||
|
attempted: true,
|
||||||
|
sent: false,
|
||||||
|
error: "smtp down",
|
||||||
|
});
|
||||||
|
expect(r.nostr.attempted).toBe(false);
|
||||||
|
|
||||||
fetchMock.mockResolvedValueOnce(okJson(ticket))
|
fetchMock.mockResolvedValueOnce(okJson(ticket));
|
||||||
const legacy = await api.resendTicketEmail('tid', 'admin')
|
const legacy = await api.resendTicketEmail("tid", "admin");
|
||||||
expect(legacy.ticket.id).toBe('tid')
|
expect(legacy.ticket.id).toBe("tid");
|
||||||
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
|
expect(legacy.email).toEqual({
|
||||||
})
|
attempted: false,
|
||||||
})
|
sent: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('TicketApiService promo codes', () => {
|
describe("TicketApiService promo codes", () => {
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn();
|
||||||
let api: TicketApiService
|
let api: TicketApiService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset()
|
fetchMock.mockReset();
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
api = new TicketApiService({ baseUrl: BASE, apiKey: "inst-key" });
|
||||||
})
|
});
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
function lastCall() {
|
function lastCall() {
|
||||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
|
return {
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
body: JSON.parse(init.body as string),
|
||||||
|
headers: init.headers as Record<string, string>,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => {
|
it("validatePromoCode posts the v2 shape with an upper-cased code", async () => {
|
||||||
const totals = {
|
const totals = {
|
||||||
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
|
subtotal: 2000,
|
||||||
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }],
|
discount: 1000,
|
||||||
}
|
total: 1000,
|
||||||
fetchMock.mockResolvedValue(okJson(totals))
|
currency: "sat",
|
||||||
const res = await api.validatePromoCode('evt', ' half ', 2)
|
discounts_applied: [
|
||||||
const { url, init, body } = lastCall()
|
{
|
||||||
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`)
|
code: "HALF",
|
||||||
expect(init.method).toBe('POST')
|
discount_percent: 50,
|
||||||
expect(body).toEqual({ codes: ['HALF'], quantity: 2 })
|
discount_fixed: null,
|
||||||
expect(res).toEqual(totals)
|
amount_saved: 1000,
|
||||||
})
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fetchMock.mockResolvedValue(okJson(totals));
|
||||||
|
const res = await api.validatePromoCode("evt", " half ", 2);
|
||||||
|
const { url, init, body } = lastCall();
|
||||||
|
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`);
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(body).toEqual({ codes: ["HALF"], quantity: 2 });
|
||||||
|
expect(res).toEqual(totals);
|
||||||
|
});
|
||||||
|
|
||||||
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => {
|
it("carries the HTTP status on errors so a missing route (older extension) is recognisable", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response)
|
fetchMock.mockResolvedValue({
|
||||||
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 })
|
ok: false,
|
||||||
})
|
status: 404,
|
||||||
|
json: async () => ({ detail: "Not Found" }),
|
||||||
|
} as Response);
|
||||||
|
await expect(api.validatePromoCode("evt", "HALF")).rejects.toMatchObject({
|
||||||
|
message: "Not Found",
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('requestTicket forwards promo_code verbatim', async () => {
|
it("requestTicket forwards promo_code verbatim", async () => {
|
||||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
fetchMock.mockResolvedValue(
|
||||||
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
|
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
|
||||||
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
|
);
|
||||||
})
|
await api.requestTicket("evt", {
|
||||||
|
userId: "u1",
|
||||||
|
promoCode: "HALF",
|
||||||
|
quantity: 2,
|
||||||
|
});
|
||||||
|
expect(lastCall().body).toEqual({
|
||||||
|
user_id: "u1",
|
||||||
|
promo_code: "HALF",
|
||||||
|
quantity: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('createEvent sends extra.promo_codes verbatim', async () => {
|
it("requestTicket names the chosen ticket wave", async () => {
|
||||||
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
|
// Without it the backend refuses ("Please select a ticket wave")
|
||||||
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
|
// whenever more than one wave is open.
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
|
||||||
|
);
|
||||||
|
await api.requestTicket("evt", { userId: "u1", ticketWaveId: "regular" });
|
||||||
|
expect(lastCall().body).toEqual({
|
||||||
|
user_id: "u1",
|
||||||
|
ticket_wave_id: "regular",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requestTicket omits the wave when none was chosen", async () => {
|
||||||
|
// A single open wave is implied server-side; sending an empty value
|
||||||
|
// would be rejected as naming a wave that does not exist.
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
okJson({ payment_hash: "h", payment_request: "lnbc1", is_fiat: false }),
|
||||||
|
);
|
||||||
|
await api.requestTicket("evt", { userId: "u1" });
|
||||||
|
expect(lastCall().body).not.toHaveProperty("ticket_wave_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validatePromoCode prices against the same wave the purchase will", async () => {
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
okJson({ subtotal: 50, discount: 0, total: 50, currency: "sat" }),
|
||||||
|
);
|
||||||
|
await api.validatePromoCode("evt", "HALF", 2, "regular");
|
||||||
|
expect(lastCall().body).toEqual({
|
||||||
|
codes: ["HALF"],
|
||||||
|
quantity: 2,
|
||||||
|
ticket_wave_id: "regular",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createEvent sends extra.promo_codes verbatim", async () => {
|
||||||
|
fetchMock.mockResolvedValue(okJson({ id: "evt" }));
|
||||||
|
const promo_codes = [
|
||||||
|
{ code: "HALF", discount_percent: 50, active: true, max_uses: 2 },
|
||||||
|
];
|
||||||
await api.createEvent(
|
await api.createEvent(
|
||||||
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
|
{ name: "E", event_start_date: "2030-01-01", extra: { promo_codes } },
|
||||||
'inkey',
|
"inkey",
|
||||||
)
|
);
|
||||||
const { body, headers } = lastCall()
|
const { body, headers } = lastCall();
|
||||||
expect(body.extra.promo_codes).toEqual(promo_codes)
|
expect(body.extra.promo_codes).toEqual(promo_codes);
|
||||||
expect(headers['X-API-KEY']).toBe('inkey')
|
expect(headers["X-API-KEY"]).toBe("inkey");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,33 +11,36 @@ import type {
|
||||||
TicketedEvent,
|
TicketedEvent,
|
||||||
CreateEventRequest,
|
CreateEventRequest,
|
||||||
PromoValidateRequest,
|
PromoValidateRequest,
|
||||||
} from '../types/ticket'
|
} from "../types/ticket";
|
||||||
import { normalizePromoCode } from '../lib/promoCodes'
|
import { normalizePromoCode } from "../lib/promoCodes";
|
||||||
|
|
||||||
export interface TicketApiConfig {
|
export interface TicketApiConfig {
|
||||||
baseUrl: string
|
baseUrl: string;
|
||||||
apiKey: string
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for `TicketApiService.requestTicket`. */
|
/** Options for `TicketApiService.requestTicket`. */
|
||||||
export interface RequestTicketOptions {
|
export interface RequestTicketOptions {
|
||||||
/** LNbits user id — logged-in buyers. */
|
/** LNbits user id — logged-in buyers. */
|
||||||
userId?: string
|
userId?: string;
|
||||||
/** Guest buyer name (required together with `email` when no `userId`). */
|
/** Guest buyer name (required together with `email` when no `userId`). */
|
||||||
name?: string
|
name?: string;
|
||||||
/** Delivery email — required for guests, optional for logged-in buyers. */
|
/** Delivery email — required for guests, optional for logged-in buyers. */
|
||||||
email?: string
|
email?: string;
|
||||||
/** Bearer token; forwarded only when present. */
|
/** Bearer token; forwarded only when present. */
|
||||||
accessToken?: string
|
accessToken?: string;
|
||||||
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
|
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
|
||||||
frontendUrl?: string
|
frontendUrl?: string;
|
||||||
paymentMethod?: PaymentMethod
|
paymentMethod?: PaymentMethod;
|
||||||
fiatProvider?: string
|
fiatProvider?: string;
|
||||||
promoCode?: string
|
promoCode?: string;
|
||||||
refundAddress?: string
|
refundAddress?: string;
|
||||||
nostrIdentifier?: string
|
nostrIdentifier?: string;
|
||||||
/** Number of tickets to buy on this invoice. Backend caps at 10. */
|
/** Number of tickets to buy on this invoice. Backend caps at 10. */
|
||||||
quantity?: number
|
quantity?: number;
|
||||||
|
/** Which pricing tier to buy from. Required when several waves are
|
||||||
|
* open — the backend refuses to guess. */
|
||||||
|
ticketWaveId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,11 +59,10 @@ export class TicketApiService {
|
||||||
* Used to correlate Nostr events with ticketed events.
|
* Used to correlate Nostr events with ticketed events.
|
||||||
*/
|
*/
|
||||||
async fetchTicketedEvents(): Promise<any[]> {
|
async fetchTicketedEvents(): Promise<any[]> {
|
||||||
const response = await this.request(
|
const response = await this.request("/events/api/v1/events/public", {
|
||||||
'/events/api/v1/events/public',
|
method: "GET",
|
||||||
{ method: 'GET' }
|
});
|
||||||
)
|
return response;
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -71,10 +73,10 @@ export class TicketApiService {
|
||||||
* own event the moment it drops to `proposed`.
|
* own event the moment it drops to `proposed`.
|
||||||
*/
|
*/
|
||||||
async fetchMyEvents(invoiceKey: string): Promise<any[]> {
|
async fetchMyEvents(invoiceKey: string): Promise<any[]> {
|
||||||
return this.request('/events/api/v1/events?all_wallets=true', {
|
return this.request("/events/api/v1/events?all_wallets=true", {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: { 'X-API-KEY': invoiceKey },
|
headers: { "X-API-KEY": invoiceKey },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,29 +101,32 @@ export class TicketApiService {
|
||||||
eventId: string,
|
eventId: string,
|
||||||
options: RequestTicketOptions = {},
|
options: RequestTicketOptions = {},
|
||||||
): Promise<TicketPurchaseInvoice> {
|
): Promise<TicketPurchaseInvoice> {
|
||||||
const body: CreateTicketRequest = {}
|
const body: CreateTicketRequest = {};
|
||||||
if (options.userId) body.user_id = options.userId
|
if (options.userId) body.user_id = options.userId;
|
||||||
if (options.name) body.name = options.name
|
if (options.name) body.name = options.name;
|
||||||
if (options.email) body.email = options.email
|
if (options.email) body.email = options.email;
|
||||||
if (options.frontendUrl) body.frontend_url = options.frontendUrl
|
if (options.frontendUrl) body.frontend_url = options.frontendUrl;
|
||||||
if (options.paymentMethod) body.payment_method = options.paymentMethod
|
if (options.paymentMethod) body.payment_method = options.paymentMethod;
|
||||||
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
|
if (options.fiatProvider) body.fiat_provider = options.fiatProvider;
|
||||||
if (options.promoCode) body.promo_code = options.promoCode
|
if (options.promoCode) body.promo_code = options.promoCode;
|
||||||
if (options.refundAddress) body.refund_address = options.refundAddress
|
if (options.refundAddress) body.refund_address = options.refundAddress;
|
||||||
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
|
if (options.nostrIdentifier)
|
||||||
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
|
body.nostr_identifier = options.nostrIdentifier;
|
||||||
|
if (options.quantity && options.quantity > 1)
|
||||||
|
body.quantity = options.quantity;
|
||||||
|
if (options.ticketWaveId) body.ticket_wave_id = options.ticketWaveId;
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
const headers: Record<string, string> = {
|
||||||
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (options.accessToken)
|
||||||
|
headers["Authorization"] = `Bearer ${options.accessToken}`;
|
||||||
|
|
||||||
const data = await this.request(
|
const data = await this.request(`/events/api/v1/tickets/${eventId}`, {
|
||||||
`/events/api/v1/tickets/${eventId}`,
|
method: "POST",
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}
|
});
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentHash: data.payment_hash,
|
paymentHash: data.payment_hash,
|
||||||
|
|
@ -133,7 +138,7 @@ export class TicketApiService {
|
||||||
// and no invoice to settle.
|
// and no invoice to settle.
|
||||||
paid: Boolean(data.paid),
|
paid: Boolean(data.paid),
|
||||||
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -142,7 +147,9 @@ export class TicketApiService {
|
||||||
* the public ticket page the emailed link opens.
|
* the public ticket page the emailed link opens.
|
||||||
*/
|
*/
|
||||||
async getTicket(ticketId: string): Promise<PublicTicket> {
|
async getTicket(ticketId: string): Promise<PublicTicket> {
|
||||||
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' })
|
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
eventId: t.event,
|
eventId: t.event,
|
||||||
name: t.name ?? null,
|
name: t.name ?? null,
|
||||||
|
|
@ -150,7 +157,7 @@ export class TicketApiService {
|
||||||
paid: Boolean(t.paid),
|
paid: Boolean(t.paid),
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -160,7 +167,7 @@ export class TicketApiService {
|
||||||
* failure here as non-fatal.
|
* failure here as non-fatal.
|
||||||
*/
|
*/
|
||||||
async getPublicEvent(eventId: string): Promise<TicketedEvent> {
|
async getPublicEvent(eventId: string): Promise<TicketedEvent> {
|
||||||
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' })
|
return this.request(`/events/api/v1/events/${eventId}`, { method: "GET" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -168,18 +175,18 @@ export class TicketApiService {
|
||||||
*/
|
*/
|
||||||
async checkPaymentStatus(
|
async checkPaymentStatus(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
paymentHash: string
|
paymentHash: string,
|
||||||
): Promise<TicketPaymentStatus> {
|
): Promise<TicketPaymentStatus> {
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/tickets/${eventId}/${paymentHash}`,
|
`/events/api/v1/tickets/${eventId}/${paymentHash}`,
|
||||||
{ method: 'POST' }
|
{ method: "POST" },
|
||||||
)
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paid: data.paid === true,
|
paid: data.paid === true,
|
||||||
ticketId: data.ticket_id,
|
ticketId: data.ticket_id,
|
||||||
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -187,19 +194,16 @@ export class TicketApiService {
|
||||||
*/
|
*/
|
||||||
async fetchUserTickets(
|
async fetchUserTickets(
|
||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string
|
accessToken: string,
|
||||||
): Promise<EventTicket[]> {
|
): Promise<EventTicket[]> {
|
||||||
const data = await this.request(
|
const data = await this.request(`/events/api/v1/tickets/user/${userId}`, {
|
||||||
`/events/api/v1/tickets/user/${userId}`,
|
method: "GET",
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
)
|
|
||||||
|
|
||||||
return (data as any[]).map(t => ({
|
return (data as any[]).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
eventId: t.event,
|
eventId: t.event,
|
||||||
|
|
@ -211,7 +215,7 @@ export class TicketApiService {
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as EventTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -220,10 +224,10 @@ export class TicketApiService {
|
||||||
async validateTicket(ticketId: string): Promise<EventTicket[]> {
|
async validateTicket(ticketId: string): Promise<EventTicket[]> {
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/register/ticket/${ticketId}`,
|
`/events/api/v1/register/ticket/${ticketId}`,
|
||||||
{ method: 'GET' }
|
{ method: "GET" },
|
||||||
)
|
);
|
||||||
|
|
||||||
return (data as any[]).map(t => ({
|
return (data as any[]).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
eventId: t.event,
|
eventId: t.event,
|
||||||
|
|
@ -235,22 +239,25 @@ export class TicketApiService {
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as EventTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new event. Uses invoice key (any user).
|
* Create a new event. Uses invoice key (any user).
|
||||||
* Backend determines approval status based on user role and settings.
|
* Backend determines approval status based on user role and settings.
|
||||||
*/
|
*/
|
||||||
async createEvent(eventData: CreateEventRequest, invoiceKey: string): Promise<TicketedEvent> {
|
async createEvent(
|
||||||
return this.request('/events/api/v1/events', {
|
eventData: CreateEventRequest,
|
||||||
method: 'POST',
|
invoiceKey: string,
|
||||||
|
): Promise<TicketedEvent> {
|
||||||
|
return this.request("/events/api/v1/events", {
|
||||||
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-API-KEY': invoiceKey,
|
"X-API-KEY": invoiceKey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(eventData),
|
body: JSON.stringify(eventData),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -265,13 +272,13 @@ export class TicketApiService {
|
||||||
adminKey: string,
|
adminKey: string,
|
||||||
): Promise<TicketedEvent> {
|
): Promise<TicketedEvent> {
|
||||||
return this.request(`/events/api/v1/events/${eventId}`, {
|
return this.request(`/events/api/v1/events/${eventId}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-API-KEY': adminKey,
|
"X-API-KEY": adminKey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(eventData),
|
body: JSON.stringify(eventData),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -289,16 +296,16 @@ export class TicketApiService {
|
||||||
const r = await this.request(
|
const r = await this.request(
|
||||||
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
`/events/api/v1/tickets/${ticketId}/resend-email`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'X-API-KEY': adminKey },
|
headers: { "X-API-KEY": adminKey },
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
const t = r.ticket ?? r
|
const t = r.ticket ?? r;
|
||||||
const delivery = (d: any) => ({
|
const delivery = (d: any) => ({
|
||||||
attempted: Boolean(d?.attempted),
|
attempted: Boolean(d?.attempted),
|
||||||
sent: Boolean(d?.sent),
|
sent: Boolean(d?.sent),
|
||||||
error: d?.error ?? null,
|
error: d?.error ?? null,
|
||||||
})
|
});
|
||||||
return {
|
return {
|
||||||
ticket: {
|
ticket: {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|
@ -315,7 +322,7 @@ export class TicketApiService {
|
||||||
},
|
},
|
||||||
email: delivery(r.email),
|
email: delivery(r.email),
|
||||||
nostr: delivery(r.nostr),
|
nostr: delivery(r.nostr),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -329,23 +336,23 @@ export class TicketApiService {
|
||||||
eventId: string,
|
eventId: string,
|
||||||
adminKey: string,
|
adminKey: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
event_id: string
|
event_id: string;
|
||||||
sold: number
|
sold: number;
|
||||||
registered: number
|
registered: number;
|
||||||
remaining: number
|
remaining: number;
|
||||||
tickets: Array<{
|
tickets: Array<{
|
||||||
id: string
|
id: string;
|
||||||
name?: string | null
|
name?: string | null;
|
||||||
registered: boolean
|
registered: boolean;
|
||||||
registered_at: string | null
|
registered_at: string | null;
|
||||||
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
|
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
|
||||||
applied_promo_code?: string | null
|
applied_promo_code?: string | null;
|
||||||
}>
|
}>;
|
||||||
}> {
|
}> {
|
||||||
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: { 'X-API-KEY': adminKey },
|
headers: { "X-API-KEY": adminKey },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -355,13 +362,24 @@ export class TicketApiService {
|
||||||
* means "not valid". Older extensions 404 — the thrown error carries
|
* means "not valid". Older extensions 404 — the thrown error carries
|
||||||
* `status` so callers can fall back to "applied at checkout".
|
* `status` so callers can fall back to "applied at checkout".
|
||||||
*/
|
*/
|
||||||
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
|
async validatePromoCode(
|
||||||
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
|
eventId: string,
|
||||||
|
code: string,
|
||||||
|
quantity = 1,
|
||||||
|
ticketWaveId?: string,
|
||||||
|
): Promise<BasketTotals> {
|
||||||
|
// The preview must name the same wave the purchase will, or the quote
|
||||||
|
// and the charge are priced against different tiers.
|
||||||
|
const body: PromoValidateRequest = {
|
||||||
|
codes: [normalizePromoCode(code)],
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
if (ticketWaveId) body.ticket_wave_id = ticketWaveId;
|
||||||
return this.request(`/events/api/v1/promo/validate/${eventId}`, {
|
return this.request(`/events/api/v1/promo/validate/${eventId}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -370,11 +388,17 @@ export class TicketApiService {
|
||||||
* unpaid / already-registered / not-owned cases with HTTP errors
|
* unpaid / already-registered / not-owned cases with HTTP errors
|
||||||
* whose `detail` becomes the thrown Error message.
|
* whose `detail` becomes the thrown Error message.
|
||||||
*/
|
*/
|
||||||
async registerTicket(ticketId: string, adminKey: string): Promise<EventTicket> {
|
async registerTicket(
|
||||||
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
|
ticketId: string,
|
||||||
method: 'PUT',
|
adminKey: string,
|
||||||
headers: { 'X-API-KEY': adminKey },
|
): Promise<EventTicket> {
|
||||||
})
|
const t = await this.request(
|
||||||
|
`/events/api/v1/tickets/register/${ticketId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "X-API-KEY": adminKey },
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
wallet: t.wallet,
|
wallet: t.wallet,
|
||||||
|
|
@ -387,7 +411,7 @@ export class TicketApiService {
|
||||||
time: t.time,
|
time: t.time,
|
||||||
regTimestamp: t.reg_timestamp,
|
regTimestamp: t.reg_timestamp,
|
||||||
extra: t.extra as EventTicketExtra | undefined,
|
extra: t.extra as EventTicketExtra | undefined,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -397,13 +421,13 @@ export class TicketApiService {
|
||||||
*/
|
*/
|
||||||
async isAdmin(adminKey: string): Promise<boolean> {
|
async isAdmin(adminKey: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.request('/events/api/v1/events/all', {
|
await this.request("/events/api/v1/events/all", {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: { 'X-API-KEY': adminKey },
|
headers: { "X-API-KEY": adminKey },
|
||||||
})
|
});
|
||||||
return true
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,13 +439,16 @@ export class TicketApiService {
|
||||||
*/
|
*/
|
||||||
async getAutoApprove(invoiceKey: string): Promise<boolean> {
|
async getAutoApprove(invoiceKey: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const settings = await this.request('/events/api/v1/events/settings/public', {
|
const settings = await this.request(
|
||||||
method: 'GET',
|
"/events/api/v1/events/settings/public",
|
||||||
headers: { 'X-API-KEY': invoiceKey },
|
{
|
||||||
})
|
method: "GET",
|
||||||
return Boolean(settings?.auto_approve)
|
headers: { "X-API-KEY": invoiceKey },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Boolean(settings?.auto_approve);
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,13 +457,15 @@ export class TicketApiService {
|
||||||
*/
|
*/
|
||||||
async getCurrencies(): Promise<string[]> {
|
async getCurrencies(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const currencies = await this.request('/api/v1/currencies', { method: 'GET' })
|
const currencies = await this.request("/api/v1/currencies", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
if (Array.isArray(currencies)) {
|
if (Array.isArray(currencies)) {
|
||||||
return ['sats', ...currencies.filter((c: string) => c !== 'sats')]
|
return ["sats", ...currencies.filter((c: string) => c !== "sats")];
|
||||||
}
|
}
|
||||||
return ['sats']
|
return ["sats"];
|
||||||
} catch {
|
} catch {
|
||||||
return ['sats']
|
return ["sats"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,28 +474,31 @@ export class TicketApiService {
|
||||||
*/
|
*/
|
||||||
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'accept': 'application/json',
|
accept: "application/json",
|
||||||
'X-API-KEY': this.config.apiKey,
|
"X-API-KEY": this.config.apiKey,
|
||||||
...(init.headers as Record<string, string> ?? {}),
|
...((init.headers as Record<string, string>) ?? {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: `Request failed: ${path}` }))
|
const error = await response
|
||||||
const errorMessage = typeof error.detail === 'string'
|
.json()
|
||||||
|
.catch(() => ({ detail: `Request failed: ${path}` }));
|
||||||
|
const errorMessage =
|
||||||
|
typeof error.detail === "string"
|
||||||
? error.detail
|
? error.detail
|
||||||
: Array.isArray(error.detail)
|
: Array.isArray(error.detail)
|
||||||
? error.detail[0]?.msg ?? 'Request failed'
|
? (error.detail[0]?.msg ?? "Request failed")
|
||||||
: 'Request failed'
|
: "Request failed";
|
||||||
// Carry the HTTP status so callers can tell "route missing on an
|
// Carry the HTTP status so callers can tell "route missing on an
|
||||||
// older extension" (404) apart from "bad input" (400).
|
// older extension" (404) apart from "bad input" (400).
|
||||||
throw Object.assign(new Error(errorMessage), { status: response.status })
|
throw Object.assign(new Error(errorMessage), { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,41 +7,45 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PromoCode {
|
export interface PromoCode {
|
||||||
code: string
|
code: string;
|
||||||
discount_percent: number
|
discount_percent: number;
|
||||||
active: boolean
|
active: boolean;
|
||||||
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
|
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
|
||||||
* same name as upstream v2). */
|
* same name as upstream v2). */
|
||||||
max_uses?: number | null
|
max_uses?: number | null;
|
||||||
/** Paid tickets that used this code — derived by the backend on read,
|
/** Paid tickets that used this code — derived by the backend on read,
|
||||||
* never sent back. */
|
* never sent back. */
|
||||||
used_count?: number
|
used_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
|
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
|
||||||
* v2 shape; `quantity` stands in for v2's `items` since this fork has
|
* v2 shape; `quantity` stands in for v2's `items` since this fork has
|
||||||
* no ticket types). */
|
* no ticket types). */
|
||||||
export interface PromoValidateRequest {
|
export interface PromoValidateRequest {
|
||||||
codes: string[]
|
codes: string[];
|
||||||
quantity: number
|
quantity: number;
|
||||||
|
/** Which wave to price against. Omit only when a single wave is open —
|
||||||
|
* the preview must name the same wave the purchase will, or the quote
|
||||||
|
* and the charge disagree. */
|
||||||
|
ticket_wave_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BasketDiscount {
|
export interface BasketDiscount {
|
||||||
code: string
|
code: string;
|
||||||
discount_percent: number | null
|
discount_percent: number | null;
|
||||||
/** Always null on this fork (percent-only); kept for the v2 shape. */
|
/** Always null on this fork (percent-only); kept for the v2 shape. */
|
||||||
discount_fixed: number | null
|
discount_fixed: number | null;
|
||||||
amount_saved: number
|
amount_saved: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response of the promo validate endpoint. An empty `discounts_applied`
|
/** Response of the promo validate endpoint. An empty `discounts_applied`
|
||||||
* means none of the submitted codes is valid for this event/quantity. */
|
* means none of the submitted codes is valid for this event/quantity. */
|
||||||
export interface BasketTotals {
|
export interface BasketTotals {
|
||||||
subtotal: number
|
subtotal: number;
|
||||||
discount: number
|
discount: number;
|
||||||
total: number
|
total: number;
|
||||||
discounts_applied: BasketDiscount[]
|
discounts_applied: BasketDiscount[];
|
||||||
currency: string
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,73 +54,101 @@ export interface BasketTotals {
|
||||||
* and the per-event notification toggles + custom subject/body added
|
* and the per-event notification toggles + custom subject/body added
|
||||||
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A time-boxed pricing tier (events ext >= v1.6.8).
|
||||||
|
*
|
||||||
|
* Price, currency, stock and the fiat flags belong to the wave, not the
|
||||||
|
* event: `sync_event_ticket_waves` makes the event-level fields derived —
|
||||||
|
* `amount_tickets` is the sum across waves, `price_per_ticket` and
|
||||||
|
* `currency` are the FIRST wave's. Never price or count from the event.
|
||||||
|
*/
|
||||||
|
export interface TicketWave {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** YYYY-MM-DD. Day granularity — a wave opens and closes on whole days. */
|
||||||
|
opening_date: string;
|
||||||
|
closing_date: string;
|
||||||
|
currency: string;
|
||||||
|
use_ticket_image?: boolean;
|
||||||
|
ticket_image_id?: string | null;
|
||||||
|
allow_fiat?: boolean;
|
||||||
|
fiat_currency?: string;
|
||||||
|
/** Remaining stock in this wave, decremented per sale. */
|
||||||
|
amount_tickets: number;
|
||||||
|
price_per_ticket: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventExtra {
|
export interface EventExtra {
|
||||||
/** Present on organizer-authenticated records only: public event
|
/** Present on organizer-authenticated records only: public event
|
||||||
* responses (`/events/public`, `/events/{id}`) no longer carry the
|
* responses (`/events/public`, `/events/{id}`) no longer carry the
|
||||||
* code list (events ext >= v1.6.1-aio.12). */
|
* code list (events ext >= v1.6.1-aio.12). */
|
||||||
promo_codes?: PromoCode[]
|
promo_codes?: PromoCode[];
|
||||||
conditional: boolean
|
conditional: boolean;
|
||||||
min_tickets: number
|
min_tickets: number;
|
||||||
email_notifications: boolean
|
email_notifications: boolean;
|
||||||
nostr_notifications: boolean
|
nostr_notifications: boolean;
|
||||||
notification_subject: string
|
notification_subject: string;
|
||||||
notification_body: string
|
notification_body: string;
|
||||||
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
|
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
|
||||||
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
|
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
|
||||||
* Same shape as upstream v2's `extra.payment_methods`. */
|
* Same shape as upstream v2's `extra.payment_methods`. */
|
||||||
payment_methods?: string[]
|
payment_methods?: string[];
|
||||||
|
/** Pricing tiers. Present on public responses too (events ext
|
||||||
|
* >= v1.6.8-aio.2) so a buyer can pick one. Absent on events from
|
||||||
|
* before waves existed — treat that as a single implied wave. */
|
||||||
|
ticket_waves?: TicketWave[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventTicketExtra {
|
export interface EventTicketExtra {
|
||||||
applied_promo_code?: string | null
|
applied_promo_code?: string | null;
|
||||||
sats_paid?: number | null
|
sats_paid?: number | null;
|
||||||
refund_address?: string | null
|
refund_address?: string | null;
|
||||||
nostr_identifier?: string | null
|
nostr_identifier?: string | null;
|
||||||
ticket_base_url?: string | null
|
ticket_base_url?: string | null;
|
||||||
email_notification_sent: boolean
|
email_notification_sent: boolean;
|
||||||
nostr_notification_sent: boolean
|
nostr_notification_sent: boolean;
|
||||||
refunded: boolean
|
refunded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventTicket {
|
export interface EventTicket {
|
||||||
id: string
|
id: string;
|
||||||
wallet: string
|
wallet: string;
|
||||||
/** Reference to the event (LNbits event ID) */
|
/** Reference to the event (LNbits event ID) */
|
||||||
eventId: string
|
eventId: string;
|
||||||
/** Ticket holder name */
|
/** Ticket holder name */
|
||||||
name: string | null
|
name: string | null;
|
||||||
/** Ticket holder email */
|
/** Ticket holder email */
|
||||||
email: string | null
|
email: string | null;
|
||||||
/** Nostr pubkey or LNbits user ID */
|
/** Nostr pubkey or LNbits user ID */
|
||||||
userId: string | null
|
userId: string | null;
|
||||||
/** Whether ticket has been scanned/registered at the door */
|
/** Whether ticket has been scanned/registered at the door */
|
||||||
registered: boolean
|
registered: boolean;
|
||||||
/** Whether payment has been confirmed */
|
/** Whether payment has been confirmed */
|
||||||
paid: boolean
|
paid: boolean;
|
||||||
/** Ticket creation timestamp */
|
/** Ticket creation timestamp */
|
||||||
time: string
|
time: string;
|
||||||
/** Registration/scan timestamp */
|
/** Registration/scan timestamp */
|
||||||
regTimestamp: string
|
regTimestamp: string;
|
||||||
/** Optional metadata — promo code applied, sats paid, notification
|
/** Optional metadata — promo code applied, sats paid, notification
|
||||||
* delivery flags, refund state. May be absent on older tickets. */
|
* delivery flags, refund state. May be absent on older tickets. */
|
||||||
extra?: EventTicketExtra
|
extra?: EventTicketExtra;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
|
export type TicketStatus = "pending" | "paid" | "registered" | "cancelled";
|
||||||
|
|
||||||
export type PaymentMethod = 'lightning' | 'fiat'
|
export type PaymentMethod = "lightning" | "fiat";
|
||||||
|
|
||||||
export interface TicketPurchaseRequest {
|
export interface TicketPurchaseRequest {
|
||||||
eventId: string
|
eventId: string;
|
||||||
userId: string
|
userId: string;
|
||||||
accessToken: string
|
accessToken: string;
|
||||||
/** Lightning (default) or fiat. Only meaningful if the event has
|
/** Lightning (default) or fiat. Only meaningful if the event has
|
||||||
* `allow_fiat=true` on the backend; otherwise the backend coerces
|
* `allow_fiat=true` on the backend; otherwise the backend coerces
|
||||||
* to lightning. */
|
* to lightning. */
|
||||||
paymentMethod?: PaymentMethod
|
paymentMethod?: PaymentMethod;
|
||||||
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
|
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
|
||||||
* user's default if omitted. */
|
* user's default if omitted. */
|
||||||
fiatProvider?: string
|
fiatProvider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -128,26 +160,26 @@ export interface TicketPurchaseRequest {
|
||||||
* scannable rows so the client skips the payment step entirely.
|
* scannable rows so the client skips the payment step entirely.
|
||||||
*/
|
*/
|
||||||
export interface TicketPurchaseInvoice {
|
export interface TicketPurchaseInvoice {
|
||||||
paymentHash: string
|
paymentHash: string;
|
||||||
paymentRequest?: string
|
paymentRequest?: string;
|
||||||
fiatPaymentRequest?: string
|
fiatPaymentRequest?: string;
|
||||||
fiatProvider?: string
|
fiatProvider?: string;
|
||||||
isFiat: boolean
|
isFiat: boolean;
|
||||||
/** Free tickets: already issued + paid, no invoice to settle. */
|
/** Free tickets: already issued + paid, no invoice to settle. */
|
||||||
paid?: boolean
|
paid?: boolean;
|
||||||
/** Row ids returned inline for the free path (no poll needed). */
|
/** Row ids returned inline for the free path (no poll needed). */
|
||||||
ticketIds?: string[]
|
ticketIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketPaymentStatus {
|
export interface TicketPaymentStatus {
|
||||||
paid: boolean
|
paid: boolean;
|
||||||
/** First ticket id created on this invoice. Back-compat with
|
/** First ticket id created on this invoice. Back-compat with
|
||||||
* single-ticket purchases — equals the payment_hash. */
|
* single-ticket purchases — equals the payment_hash. */
|
||||||
ticketId?: string
|
ticketId?: string;
|
||||||
/** Every row created on this invoice — one for single-ticket
|
/** Every row created on this invoice — one for single-ticket
|
||||||
* purchases, N for multi-ticket. Each row is independently
|
* purchases, N for multi-ticket. Each row is independently
|
||||||
* scannable at the door. */
|
* scannable at the door. */
|
||||||
ticketIds?: string[]
|
ticketIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -159,48 +191,48 @@ export interface TicketPaymentStatus {
|
||||||
* switches the publisher between NIP-52 kind 31922 and 31923.
|
* switches the publisher between NIP-52 kind 31922 and 31923.
|
||||||
*/
|
*/
|
||||||
export interface TicketedEvent {
|
export interface TicketedEvent {
|
||||||
id: string
|
id: string;
|
||||||
wallet: string
|
wallet: string;
|
||||||
name: string
|
name: string;
|
||||||
info: string
|
info: string;
|
||||||
closing_date: string | null
|
closing_date: string | null;
|
||||||
event_start_date: string
|
event_start_date: string;
|
||||||
event_end_date: string | null
|
event_end_date: string | null;
|
||||||
currency: string
|
currency: string;
|
||||||
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
|
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
|
||||||
allow_fiat: boolean
|
allow_fiat: boolean;
|
||||||
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
|
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
|
||||||
fiat_currency: string
|
fiat_currency: string;
|
||||||
amount_tickets: number
|
amount_tickets: number;
|
||||||
price_per_ticket: number
|
price_per_ticket: number;
|
||||||
time: string
|
time: string;
|
||||||
sold: number
|
sold: number;
|
||||||
banner: string | null
|
banner: string | null;
|
||||||
location: string | null
|
location: string | null;
|
||||||
categories: string[]
|
categories: string[];
|
||||||
extra: EventExtra
|
extra: EventExtra;
|
||||||
status: string
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateEventRequest {
|
export interface CreateEventRequest {
|
||||||
wallet?: string
|
wallet?: string;
|
||||||
name: string
|
name: string;
|
||||||
info?: string
|
info?: string;
|
||||||
closing_date?: string
|
closing_date?: string;
|
||||||
event_start_date: string
|
event_start_date: string;
|
||||||
event_end_date?: string
|
event_end_date?: string;
|
||||||
currency?: string
|
currency?: string;
|
||||||
allow_fiat?: boolean
|
allow_fiat?: boolean;
|
||||||
fiat_currency?: string
|
fiat_currency?: string;
|
||||||
amount_tickets?: number
|
amount_tickets?: number;
|
||||||
price_per_ticket?: number
|
price_per_ticket?: number;
|
||||||
banner?: string | null
|
banner?: string | null;
|
||||||
location?: string | null
|
location?: string | null;
|
||||||
categories?: string[]
|
categories?: string[];
|
||||||
/** Optional — notification toggles + custom subject/body, promo
|
/** Optional — notification toggles + custom subject/body, promo
|
||||||
* codes, conditional-event config. Backend defaults to a fresh
|
* codes, conditional-event config. Backend defaults to a fresh
|
||||||
* EventExtra if omitted. */
|
* EventExtra if omitted. */
|
||||||
extra?: Partial<EventExtra>
|
extra?: Partial<EventExtra>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -214,19 +246,23 @@ export interface CreateEventRequest {
|
||||||
* under the webapp instead of the LNbits host.
|
* under the webapp instead of the LNbits host.
|
||||||
*/
|
*/
|
||||||
export interface CreateTicketRequest {
|
export interface CreateTicketRequest {
|
||||||
name?: string
|
name?: string;
|
||||||
email?: string
|
email?: string;
|
||||||
user_id?: string
|
user_id?: string;
|
||||||
/** App root (origin + base path, no trailing slash). Origin must be
|
/** App root (origin + base path, no trailing slash). Origin must be
|
||||||
* on the LNbits instance's CORS allowlist or the backend 400s. */
|
* on the LNbits instance's CORS allowlist or the backend 400s. */
|
||||||
frontend_url?: string
|
frontend_url?: string;
|
||||||
promo_code?: string
|
promo_code?: string;
|
||||||
refund_address?: string
|
refund_address?: string;
|
||||||
nostr_identifier?: string
|
nostr_identifier?: string;
|
||||||
payment_method?: PaymentMethod
|
payment_method?: PaymentMethod;
|
||||||
fiat_provider?: string
|
fiat_provider?: string;
|
||||||
/** Number of tickets on this invoice (backend bounds 1..10). */
|
/** Number of tickets on this invoice (backend bounds 1..10). */
|
||||||
quantity?: number
|
quantity?: number;
|
||||||
|
/** Which pricing tier the buyer chose. Required whenever more than one
|
||||||
|
* wave is open — the backend refuses to guess ("Please select a ticket
|
||||||
|
* wave") — and implied when exactly one is. */
|
||||||
|
ticket_wave_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -235,18 +271,18 @@ export interface CreateTicketRequest {
|
||||||
* the caller asked for.
|
* the caller asked for.
|
||||||
*/
|
*/
|
||||||
export interface PublicTicket {
|
export interface PublicTicket {
|
||||||
eventId: string
|
eventId: string;
|
||||||
name: string | null
|
name: string | null;
|
||||||
registered: boolean
|
registered: boolean;
|
||||||
paid: boolean
|
paid: boolean;
|
||||||
time: string
|
time: string;
|
||||||
regTimestamp: string
|
regTimestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationDeliveryResult {
|
export interface NotificationDeliveryResult {
|
||||||
attempted: boolean
|
attempted: boolean;
|
||||||
sent: boolean
|
sent: boolean;
|
||||||
error: string | null
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -254,7 +290,7 @@ export interface NotificationDeliveryResult {
|
||||||
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
|
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
|
||||||
*/
|
*/
|
||||||
export interface TicketResendResult {
|
export interface TicketResendResult {
|
||||||
ticket: EventTicket
|
ticket: EventTicket;
|
||||||
email: NotificationDeliveryResult
|
email: NotificationDeliveryResult;
|
||||||
nostr: NotificationDeliveryResult
|
nostr: NotificationDeliveryResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,157 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useElementVisibility } from '@vueuse/core'
|
import { useElementVisibility } from "@vueuse/core";
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from "vue-i18n";
|
||||||
import { format } from 'date-fns'
|
import { format } from "date-fns";
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from "../composables/useDateLocale";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
|
Calendar,
|
||||||
} from 'lucide-vue-next'
|
MapPin,
|
||||||
import { useEventDetail } from '../composables/useEventDetail'
|
ArrowLeft,
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
Pencil,
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
Ticket,
|
||||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
CheckCircle2,
|
||||||
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue'
|
ScanLine,
|
||||||
import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout'
|
History,
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
} from "lucide-vue-next";
|
||||||
import { useEventsStore } from '../stores/events'
|
import { useEventDetail } from "../composables/useEventDetail";
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import BookmarkButton from "../components/BookmarkButton.vue";
|
||||||
import { toastService } from '@/core/services/ToastService'
|
import OrganizerCard from "../components/OrganizerCard.vue";
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import PurchaseTicketDialog from "../components/PurchaseTicketDialog.vue";
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import CheckoutReturnDialog from "../components/CheckoutReturnDialog.vue";
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
import {
|
||||||
|
clearPendingCheckout,
|
||||||
|
readPendingCheckout,
|
||||||
|
} from "../lib/pendingCheckout";
|
||||||
|
import { useAuth } from "@/composables/useAuthService";
|
||||||
|
import { useEventsStore } from "../stores/events";
|
||||||
|
import { useOwnedTickets } from "../composables/useOwnedTickets";
|
||||||
|
import { toastService } from "@/core/services/ToastService";
|
||||||
|
import { injectService, SERVICE_TOKENS } from "@/core/di-container";
|
||||||
|
import type { TicketApiService } from "../services/TicketApiService";
|
||||||
|
import type { TicketedEvent } from "../types/ticket";
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const { t } = useI18n()
|
const { t } = useI18n();
|
||||||
|
|
||||||
const eventId = route.params.id as string
|
const eventId = route.params.id as string;
|
||||||
const { event, isLoading, error, reload } = useEventDetail(eventId)
|
const { event, isLoading, error, reload } = useEventDetail(eventId);
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale();
|
||||||
|
|
||||||
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is
|
// Owner-edit affordance: the NIP-52 d-tag we use for the event id is
|
||||||
// the same as the LNbits event id (set at publish time in
|
// the same as the LNbits event id (set at publish time in
|
||||||
// nostr_publisher.build_nip52_event). Look the user's own events up
|
// nostr_publisher.build_nip52_event). Look the user's own events up
|
||||||
// once and offer an Edit button on a match.
|
// once and offer an Edit button on a match.
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth();
|
||||||
const eventsStore = useEventsStore()
|
const eventsStore = useEventsStore();
|
||||||
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
|
const ownedLnbitsEvent = ref<TicketedEvent | null>(null);
|
||||||
|
|
||||||
async function loadOwnedEvent() {
|
async function loadOwnedEvent() {
|
||||||
ownedLnbitsEvent.value = null
|
ownedLnbitsEvent.value = null;
|
||||||
if (!isAuthenticated.value) return
|
if (!isAuthenticated.value) return;
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey;
|
||||||
if (!invoiceKey) return
|
if (!invoiceKey) return;
|
||||||
try {
|
try {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(
|
||||||
const mine = await ticketApi.fetchMyEvents(invoiceKey)
|
SERVICE_TOKENS.TICKET_API,
|
||||||
|
) as TicketApiService;
|
||||||
|
const mine = await ticketApi.fetchMyEvents(invoiceKey);
|
||||||
ownedLnbitsEvent.value =
|
ownedLnbitsEvent.value =
|
||||||
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null
|
(mine as TicketedEvent[]).find((e) => e.id === eventId) ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
ownedLnbitsEvent.value = null
|
ownedLnbitsEvent.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadOwnedEvent)
|
// Ticket waves live on the LNbits record, not in the NIP-52 tags: the
|
||||||
watch(isAuthenticated, () => loadOwnedEvent())
|
// published tags describe the ACTIVE wave but carry no wave id, and a
|
||||||
|
// buyer needs one to choose a tier. Fetched anonymously and treated as
|
||||||
|
// non-fatal — the backend answers 410 for sold-out or closed events,
|
||||||
|
// where there is nothing to pick anyway.
|
||||||
|
const publicLnbitsEvent = ref<TicketedEvent | null>(null);
|
||||||
|
|
||||||
|
async function loadPublicEvent() {
|
||||||
|
try {
|
||||||
|
const ticketApi = injectService(
|
||||||
|
SERVICE_TOKENS.TICKET_API,
|
||||||
|
) as TicketApiService;
|
||||||
|
publicLnbitsEvent.value = await ticketApi.getPublicEvent(eventId);
|
||||||
|
} catch {
|
||||||
|
publicLnbitsEvent.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whichever LNbits record we have; the organizer's carries the same waves. */
|
||||||
|
const lnbitsEvent = computed<TicketedEvent | null>(
|
||||||
|
() => ownedLnbitsEvent.value ?? publicLnbitsEvent.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(loadOwnedEvent);
|
||||||
|
onMounted(loadPublicEvent);
|
||||||
|
watch(isAuthenticated, () => loadOwnedEvent());
|
||||||
|
|
||||||
function openEditDialog() {
|
function openEditDialog() {
|
||||||
if (!ownedLnbitsEvent.value) return
|
if (!ownedLnbitsEvent.value) return;
|
||||||
eventsStore.editingEvent = ownedLnbitsEvent.value
|
eventsStore.editingEvent = ownedLnbitsEvent.value;
|
||||||
eventsStore.showCreateDialog = true
|
eventsStore.showCreateDialog = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openScannerPage() {
|
function openScannerPage() {
|
||||||
router.push({ name: 'scan-tickets', params: { eventId } })
|
router.push({ name: "scan-tickets", params: { eventId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
if (!event.value) return ''
|
if (!event.value) return "";
|
||||||
const a = event.value
|
const a = event.value;
|
||||||
const opts = { locale: dateLocale.value }
|
const opts = { locale: dateLocale.value };
|
||||||
if (a.type === 'date') {
|
if (a.type === "date") {
|
||||||
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
|
const start = format(a.startDate, "EEEE, MMMM d, yyyy", opts);
|
||||||
if (a.endDate) {
|
if (a.endDate) {
|
||||||
return `${start} — ${format(a.endDate, 'EEEE, MMMM d, yyyy', opts)}`
|
return `${start} — ${format(a.endDate, "EEEE, MMMM d, yyyy", opts)}`;
|
||||||
}
|
}
|
||||||
return start
|
return start;
|
||||||
}
|
}
|
||||||
// Time-based event. If start and end share the same calendar day,
|
// Time-based event. If start and end share the same calendar day,
|
||||||
// show end as time-only ("19:00 — 21:45"). Otherwise repeat the full
|
// show end as time-only ("19:00 — 21:45"). Otherwise repeat the full
|
||||||
// date on the end side so a multi-day event reads unambiguously
|
// date on the end side so a multi-day event reads unambiguously
|
||||||
// ("May 29 • 19:00 — May 30 • 21:45").
|
// ("May 29 • 19:00 — May 30 • 21:45").
|
||||||
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm'
|
const FULL = "EEEE, MMMM d, yyyy \u2022 HH:mm";
|
||||||
const start = format(a.startDate, FULL, opts)
|
const start = format(a.startDate, FULL, opts);
|
||||||
if (!a.endDate) return start
|
if (!a.endDate) return start;
|
||||||
const sameDay =
|
const sameDay =
|
||||||
a.startDate.getFullYear() === a.endDate.getFullYear() &&
|
a.startDate.getFullYear() === a.endDate.getFullYear() &&
|
||||||
a.startDate.getMonth() === a.endDate.getMonth() &&
|
a.startDate.getMonth() === a.endDate.getMonth() &&
|
||||||
a.startDate.getDate() === a.endDate.getDate()
|
a.startDate.getDate() === a.endDate.getDate();
|
||||||
return `${start} — ${format(a.endDate, sameDay ? 'HH:mm' : FULL, opts)}`
|
return `${start} — ${format(a.endDate, sameDay ? "HH:mm" : FULL, opts)}`;
|
||||||
})
|
});
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
if (!event.value?.category) return null
|
if (!event.value?.category) return null;
|
||||||
return t(`events.categories.${event.value.category}`, event.value.category)
|
return t(`events.categories.${event.value.category}`, event.value.category);
|
||||||
})
|
});
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'events' })
|
router.push({ name: "events" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ticket purchase + owned-tickets surface ----------------------
|
// --- Ticket purchase + owned-tickets surface ----------------------
|
||||||
|
|
||||||
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
|
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets();
|
||||||
|
|
||||||
const ownedPaidCount = computed(() => paidCount(eventId))
|
const ownedPaidCount = computed(() => paidCount(eventId));
|
||||||
|
|
||||||
const purchaseEvent = computed(() => {
|
const purchaseEvent = computed(() => {
|
||||||
const a = event.value
|
const a = event.value;
|
||||||
if (!a || !a.ticketInfo) return null
|
if (!a || !a.ticketInfo) return null;
|
||||||
|
// Nostr tags describe the active wave but name no wave id, so the
|
||||||
|
// pickable tiers come from the LNbits record when we have one. The tag
|
||||||
|
// values stay the fallback for events with no waves at all.
|
||||||
|
const lnbits = lnbitsEvent.value;
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.title,
|
name: a.title,
|
||||||
|
|
@ -120,45 +160,52 @@ const purchaseEvent = computed(() => {
|
||||||
allow_fiat: a.ticketInfo.allowFiat,
|
allow_fiat: a.ticketInfo.allowFiat,
|
||||||
fiat_currency: a.ticketInfo.fiatCurrency,
|
fiat_currency: a.ticketInfo.fiatCurrency,
|
||||||
payment_methods: a.ticketInfo.paymentMethods,
|
payment_methods: a.ticketInfo.paymentMethods,
|
||||||
}
|
// Fields the dialog synthesizes an implied wave from when an event
|
||||||
})
|
// predates waves entirely.
|
||||||
|
closing_date: lnbits?.closing_date ?? null,
|
||||||
|
event_start_date: lnbits?.event_start_date,
|
||||||
|
event_end_date: lnbits?.event_end_date ?? null,
|
||||||
|
amount_tickets: lnbits?.amount_tickets ?? a.ticketInfo.available,
|
||||||
|
extra: lnbits?.extra ? { ticket_waves: lnbits.extra.ticket_waves } : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// available === undefined → unlimited capacity, button always shown
|
// available === undefined → unlimited capacity, button always shown
|
||||||
// available === 0 → sold out, button hidden
|
// available === 0 → sold out, button hidden
|
||||||
// available > 0 → button shown
|
// available > 0 → button shown
|
||||||
const canBuyTicket = computed(() => {
|
const canBuyTicket = computed(() => {
|
||||||
const info = event.value?.ticketInfo
|
const info = event.value?.ticketInfo;
|
||||||
if (!info) return false
|
if (!info) return false;
|
||||||
return info.available === undefined || info.available > 0
|
return info.available === undefined || info.available > 0;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Past events can't be bought into. The notice below replaces the
|
// Past events can't be bought into. The notice below replaces the
|
||||||
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
// buy CTA so the flow is unambiguous — date alone is easy to miss
|
||||||
// on a long detail page.
|
// on a long detail page.
|
||||||
const isPast = computed(() => {
|
const isPast = computed(() => {
|
||||||
const a = event.value
|
const a = event.value;
|
||||||
if (!a) return false
|
if (!a) return false;
|
||||||
const end = a.endDate ?? a.startDate
|
const end = a.endDate ?? a.startDate;
|
||||||
if (!end || isNaN(end.getTime())) return false
|
if (!end || isNaN(end.getTime())) return false;
|
||||||
return end.getTime() < Date.now()
|
return end.getTime() < Date.now();
|
||||||
})
|
});
|
||||||
|
|
||||||
const showPurchaseDialog = ref(false)
|
const showPurchaseDialog = ref(false);
|
||||||
|
|
||||||
function openPurchaseDialog() {
|
function openPurchaseDialog() {
|
||||||
// Paid tickets are open to guests (name + email identity). Free
|
// Paid tickets are open to guests (name + email identity). Free
|
||||||
// claims still need an account so seats can't be drained anonymously.
|
// claims still need an account so seats can't be drained anonymously.
|
||||||
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0
|
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0;
|
||||||
if (!isAuthenticated.value && isFreeEvent) {
|
if (!isAuthenticated.value && isFreeEvent) {
|
||||||
toastService.info(t('events.detail.loginToBuyTickets'), {
|
toastService.info(t("events.detail.loginToBuyTickets"), {
|
||||||
action: {
|
action: {
|
||||||
label: t('events.detail.logIn'),
|
label: t("events.detail.logIn"),
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push("/login"),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
showPurchaseDialog.value = true
|
showPurchaseDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Return from the fiat provider ---------------------------------
|
// --- Return from the fiat provider ---------------------------------
|
||||||
|
|
@ -167,36 +214,37 @@ function openPurchaseDialog() {
|
||||||
// or ?checkout=cancelled. The query is stripped immediately so the
|
// or ?checkout=cancelled. The query is stripped immediately so the
|
||||||
// ticket ids don't linger in history; the localStorage record written
|
// ticket ids don't linger in history; the localStorage record written
|
||||||
// before the redirect is the fallback source and carries the email.
|
// before the redirect is the fallback source and carries the email.
|
||||||
const showReturnDialog = ref(false)
|
const showReturnDialog = ref(false);
|
||||||
const returnTicketIds = ref<string[]>([])
|
const returnTicketIds = ref<string[]>([]);
|
||||||
const returnEmail = ref<string | undefined>(undefined)
|
const returnEmail = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
function handleCheckoutReturn() {
|
function handleCheckoutReturn() {
|
||||||
const status = route.query.checkout
|
const status = route.query.checkout;
|
||||||
if (status !== 'success' && status !== 'cancelled') return
|
if (status !== "success" && status !== "cancelled") return;
|
||||||
|
|
||||||
const pending = readPendingCheckout()
|
const pending = readPendingCheckout();
|
||||||
const samePurchase = pending?.eventId === eventId
|
const samePurchase = pending?.eventId === eventId;
|
||||||
void router.replace({ query: {} })
|
void router.replace({ query: {} });
|
||||||
|
|
||||||
if (status === 'cancelled') {
|
if (status === "cancelled") {
|
||||||
clearPendingCheckout()
|
clearPendingCheckout();
|
||||||
toastService.info(t('events.checkout.cancelled'))
|
toastService.info(t("events.checkout.cancelled"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromQuery = String(route.query.tickets ?? '')
|
const fromQuery = String(route.query.tickets ?? "")
|
||||||
.split(',')
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean);
|
||||||
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : []
|
const ids =
|
||||||
if (ids.length === 0) return
|
fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : [];
|
||||||
returnTicketIds.value = ids
|
if (ids.length === 0) return;
|
||||||
returnEmail.value = samePurchase ? pending?.email : undefined
|
returnTicketIds.value = ids;
|
||||||
showReturnDialog.value = true
|
returnEmail.value = samePurchase ? pending?.email : undefined;
|
||||||
|
showReturnDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(handleCheckoutReturn)
|
onMounted(handleCheckoutReturn);
|
||||||
|
|
||||||
// --- Sticky buy bar --------------------------------------------------
|
// --- Sticky buy bar --------------------------------------------------
|
||||||
// The ticket block sits right under the date/location so it is above the
|
// The ticket block sits right under the date/location so it is above the
|
||||||
|
|
@ -204,20 +252,22 @@ onMounted(handleCheckoutReturn)
|
||||||
// it leaves the viewport a compact bar with the price + the same CTA
|
// it leaves the viewport a compact bar with the price + the same CTA
|
||||||
// pins above the bottom nav so the primary action is never more than a
|
// pins above the bottom nav so the primary action is never more than a
|
||||||
// tap away. Hidden while a dialog is open (the dialog is the action).
|
// tap away. Hidden while a dialog is open (the dialog is the action).
|
||||||
const ticketBlockRef = ref<HTMLElement | null>(null)
|
const ticketBlockRef = ref<HTMLElement | null>(null);
|
||||||
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
|
const ticketBlockVisible = useElementVisibility(ticketBlockRef);
|
||||||
|
|
||||||
const priceLabel = computed(() => {
|
const priceLabel = computed(() => {
|
||||||
const info = event.value?.ticketInfo
|
const info = event.value?.ticketInfo;
|
||||||
if (!info) return ''
|
if (!info) return "";
|
||||||
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
|
return info.price === 0
|
||||||
})
|
? t("events.detail.free")
|
||||||
|
: `${info.price} ${info.currency}`;
|
||||||
|
});
|
||||||
|
|
||||||
const buyLabel = computed(() =>
|
const buyLabel = computed(() =>
|
||||||
ownedPaidCount.value > 0
|
ownedPaidCount.value > 0
|
||||||
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
|
? t("events.detail.buyAnotherTicket", "Buy another ticket")
|
||||||
: t('events.detail.buyTicket', 'Buy ticket'),
|
: t("events.detail.buyTicket", "Buy ticket"),
|
||||||
)
|
);
|
||||||
|
|
||||||
const showStickyCta = computed(
|
const showStickyCta = computed(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -228,22 +278,25 @@ const showStickyCta = computed(
|
||||||
!ticketBlockVisible.value &&
|
!ticketBlockVisible.value &&
|
||||||
!showPurchaseDialog.value &&
|
!showPurchaseDialog.value &&
|
||||||
!showReturnDialog.value,
|
!showReturnDialog.value,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Re-fetch the user's tickets when the purchase dialog closes (the
|
// Re-fetch the user's tickets when the purchase dialog closes (the
|
||||||
// buyer may have just paid). The inventory side updates automatically
|
// buyer may have just paid). The inventory side updates automatically
|
||||||
// via the relay republish from the events extension.
|
// via the relay republish from the events extension.
|
||||||
watch(showPurchaseDialog, (open) => {
|
watch(showPurchaseDialog, (open) => {
|
||||||
if (!open) refreshOwnedTickets()
|
if (!open) refreshOwnedTickets();
|
||||||
})
|
});
|
||||||
|
|
||||||
function goToMyTickets() {
|
function goToMyTickets() {
|
||||||
router.push('/my-tickets')
|
router.push("/my-tickets");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }">
|
<div
|
||||||
|
class="container mx-auto py-6 px-4 max-w-3xl"
|
||||||
|
:class="{ 'pb-24': showStickyCta }"
|
||||||
|
>
|
||||||
<!-- Top bar — back-link only. Edit moves into the title row as a
|
<!-- Top bar — back-link only. Edit moves into the title row as a
|
||||||
prominent icon button; Scan moves into the tickets section
|
prominent icon button; Scan moves into the tickets section
|
||||||
where it replaces the Buy-ticket CTA for the host. -->
|
where it replaces the Buy-ticket CTA for the host. -->
|
||||||
|
|
@ -264,7 +317,9 @@ function goToMyTickets() {
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-else-if="error" class="text-center py-16">
|
<div v-else-if="error" class="text-center py-16">
|
||||||
<h2 class="text-xl font-semibold text-foreground mb-2">Event not found</h2>
|
<h2 class="text-xl font-semibold text-foreground mb-2">
|
||||||
|
Event not found
|
||||||
|
</h2>
|
||||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||||
<Button variant="outline" @click="reload">Retry</Button>
|
<Button variant="outline" @click="reload">Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -286,11 +341,7 @@ function goToMyTickets() {
|
||||||
<!-- "Yours" leads the row in the highlighted variant so the
|
<!-- "Yours" leads the row in the highlighted variant so the
|
||||||
ownership signal stands out against the neutral
|
ownership signal stands out against the neutral
|
||||||
category/tag chips that follow. -->
|
category/tag chips that follow. -->
|
||||||
<Badge
|
<Badge v-if="event.isMine" variant="secondary" class="shrink-0">
|
||||||
v-if="event.isMine"
|
|
||||||
variant="secondary"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
|
||||||
Yours
|
Yours
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
|
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
|
||||||
|
|
@ -298,10 +349,14 @@ function goToMyTickets() {
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
:variant="
|
||||||
|
event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'
|
||||||
|
"
|
||||||
class="shrink-0 capitalize"
|
class="shrink-0 capitalize"
|
||||||
>
|
>
|
||||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{
|
||||||
|
event.lnbitsStatus === "rejected" ? "Rejected" : "Pending review"
|
||||||
|
}}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||||
|
|
@ -342,7 +397,9 @@ function goToMyTickets() {
|
||||||
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
|
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ dateDisplay }}
|
{{ dateDisplay }}
|
||||||
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
|
<span v-if="event.timezone" class="opacity-70"
|
||||||
|
>({{ event.timezone }})</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="event.location" class="flex items-start gap-1.5">
|
<div v-if="event.location" class="flex items-start gap-1.5">
|
||||||
|
|
@ -364,7 +421,7 @@ function goToMyTickets() {
|
||||||
@click="openScannerPage"
|
@click="openScannerPage"
|
||||||
>
|
>
|
||||||
<ScanLine class="w-4 h-4" />
|
<ScanLine class="w-4 h-4" />
|
||||||
{{ t('events.detail.scanTickets', 'Scan tickets') }}
|
{{ t("events.detail.scanTickets", "Scan tickets") }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Tickets — gated on the event carrying ticketInfo (set
|
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||||
|
|
@ -384,13 +441,21 @@ function goToMyTickets() {
|
||||||
v-if="ownedPaidCount > 0"
|
v-if="ownedPaidCount > 0"
|
||||||
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
<div
|
||||||
|
class="flex items-center gap-2 text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
||||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
{{
|
||||||
|
t(
|
||||||
|
"events.detail.ticketsOwned",
|
||||||
|
{ count: ownedPaidCount },
|
||||||
|
ownedPaidCount,
|
||||||
|
)
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
{{ t("events.detail.viewMyTickets", "View in My Tickets") }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -399,7 +464,7 @@ function goToMyTickets() {
|
||||||
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
|
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
|
||||||
>
|
>
|
||||||
<History class="w-4 h-4 shrink-0" />
|
<History class="w-4 h-4 shrink-0" />
|
||||||
{{ t('events.detail.pastEvent', 'This event has already happened') }}
|
{{ t("events.detail.pastEvent", "This event has already happened") }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="canBuyTicket" class="space-y-1">
|
<div v-else-if="canBuyTicket" class="space-y-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -414,10 +479,14 @@ function goToMyTickets() {
|
||||||
</Button>
|
</Button>
|
||||||
<p class="text-xs text-muted-foreground text-center">
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
<span v-if="event.ticketInfo.available === undefined">
|
<span v-if="event.ticketInfo.available === undefined">
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t("events.detail.unlimitedTickets", "Unlimited tickets") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
{{
|
||||||
|
t("events.detail.ticketsAvailable", {
|
||||||
|
count: event.ticketInfo.available,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -425,7 +494,7 @@ function goToMyTickets() {
|
||||||
v-else-if="ownedPaidCount === 0"
|
v-else-if="ownedPaidCount === 0"
|
||||||
class="text-sm text-destructive text-center"
|
class="text-sm text-destructive text-center"
|
||||||
>
|
>
|
||||||
{{ t('events.detail.soldOut') }}
|
{{ t("events.detail.soldOut") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -471,18 +540,36 @@ function goToMyTickets() {
|
||||||
role="region"
|
role="region"
|
||||||
:aria-label="t('events.detail.tickets')"
|
:aria-label="t('events.detail.tickets')"
|
||||||
>
|
>
|
||||||
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
|
<div
|
||||||
|
class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3"
|
||||||
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
|
<p class="text-sm font-medium text-foreground truncate">
|
||||||
|
{{ event.title }}
|
||||||
|
</p>
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
<span v-if="ownedPaidCount > 0">
|
<span v-if="ownedPaidCount > 0">
|
||||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
{{
|
||||||
|
t(
|
||||||
|
"events.detail.ticketsOwned",
|
||||||
|
{ count: ownedPaidCount },
|
||||||
|
ownedPaidCount,
|
||||||
|
)
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="event.ticketInfo?.available === undefined">
|
<span v-else-if="event.ticketInfo?.available === undefined">
|
||||||
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ priceLabel }} ·
|
||||||
|
{{
|
||||||
|
t("events.detail.unlimitedTickets", "Unlimited tickets")
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
{{ priceLabel }} ·
|
||||||
|
{{
|
||||||
|
t("events.detail.ticketsAvailable", {
|
||||||
|
count: event.ticketInfo.available,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -497,7 +584,7 @@ function goToMyTickets() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
|
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
{{ t("events.detail.viewMyTickets", "View in My Tickets") }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -513,8 +600,10 @@ function goToMyTickets() {
|
||||||
|
|
||||||
<!-- Organizer -->
|
<!-- Organizer -->
|
||||||
<div class="bg-muted/50 rounded-lg p-4">
|
<div class="bg-muted/50 rounded-lg p-4">
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
<p
|
||||||
{{ t('events.detail.organizer') }}
|
class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"
|
||||||
|
>
|
||||||
|
{{ t("events.detail.organizer") }}
|
||||||
</p>
|
</p>
|
||||||
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue