// --------------------------------------------------------------------------- // Date / time formatting // --------------------------------------------------------------------------- // All helpers pin the timezone to America/Asuncion so the output is identical // on the server (often UTC) and the client (user's local TZ). This prevents // React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client). // // IMPORTANT — parseDate() must be used instead of raw `new Date(str)` so that // ISO-like strings without a timezone suffix (e.g. "2026-04-02T14:00:00") are // always treated as UTC. Without this, the same string produces a different // instant on server (Node TZ) vs client (browser / DevTools TZ). // --------------------------------------------------------------------------- export const EVENT_TIMEZONE = 'America/Asuncion'; type Locale = 'en' | 'es'; function pickLocale(locale: Locale): string { return locale === 'es' ? 'es-ES' : 'en-US'; } // Matches ISO-like strings that have NO timezone indicator (Z, +HH:MM, etc.) const NAIVE_ISO_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/; /** * Parse a date string into a deterministic Date object. * * If the string looks like an ISO datetime but lacks a timezone suffix it is * ambiguous — `new Date()` would interpret it in the environment's local * timezone which differs between Node (SSR) and the browser (hydration). * We normalise by appending "Z" so parsing always targets UTC. */ export function parseDate(dateStr: string): Date { if (NAIVE_ISO_RE.test(dateStr)) { return new Date(dateStr + 'Z'); } return new Date(dateStr); } /** * "Sat, Feb 14" / "sáb, 14 feb" */ export function formatDateShort(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { weekday: 'short', month: 'short', day: 'numeric', timeZone: EVENT_TIMEZONE, }); } /** * "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026" */ export function formatDateLong(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: EVENT_TIMEZONE, }); } /** * "February 14, 2026" / "14 de febrero de 2026" (no weekday) */ export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { year: 'numeric', month: 'long', day: 'numeric', timeZone: EVENT_TIMEZONE, }); } /** * "Feb 14, 2026" / "14 feb 2026" */ export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), { month: 'short', day: 'numeric', year: 'numeric', timeZone: EVENT_TIMEZONE, }); } /** * "04:30 PM" / "16:30" */ export function formatTime(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleTimeString(pickLocale(locale), { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE, }); } /** * "Feb 14, 2026, 04:30 PM" — compact date + time combined */ export function formatDateTime(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleString(pickLocale(locale), { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE, }); } /** * "Sat, Feb 14, 04:30 PM" — short date + time combined */ export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string { return parseDate(dateStr).toLocaleString(pickLocale(locale), { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE, }); } // --------------------------------------------------------------------------- // Price formatting // --------------------------------------------------------------------------- /** * Format price - shows decimals only if needed * Uses space as thousands separator (common in Paraguay) * Examples: * 45000 PYG -> "45 000 PYG" (no decimals) * 41.44 PYG -> "41,44 PYG" (with decimals) */ export function formatPrice(price: number, currency: string = 'PYG'): string { const hasDecimals = price % 1 !== 0; // Format the integer and decimal parts separately const intPart = Math.floor(Math.abs(price)); const decPart = Math.abs(price) - intPart; // Format integer part with space as thousands separator const intFormatted = intPart.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); // Build final string let result = price < 0 ? '-' : ''; result += intFormatted; // Add decimals only if present if (hasDecimals) { const decStr = decPart.toFixed(2).substring(2); // Get just the decimal digits result += ',' + decStr; } return `${result} ${currency}`; } /** * Format currency amount (alias for formatPrice for backward compatibility) */ export function formatCurrency(amount: number, currency: string = 'PYG'): string { return formatPrice(amount, currency); } /** * Format a Paraguayan RUC for display as "base-checkdigit" (e.g. 1234567-9). * Legacy records stored digits only; insert the dash before the check digit. */ export function formatRucDisplay(ruc: string | null | undefined): string { if (!ruc) return ''; if (ruc.includes('-')) return ruc; const digits = ruc.replace(/\D/g, ''); if (digits.length < 2) return ruc; return `${digits.slice(0, -1)}-${digits.slice(-1)}`; } // --------------------------------------------------------------------------- // Payment helpers // --------------------------------------------------------------------------- type TpagoLinkConfig = { tpagoLink?: string | null; tpagoLink2?: string | null; tpagoLink3?: string | null; tpagoLink4?: string | null; tpagoLink5?: string | null; }; /** * Select the TPago payment link that matches the number of tickets being * purchased (1-5). Each link has a fixed amount baked in, so the quantity * determines which one to use. Falls back to the base (1-ticket) link when a * specific quantity link isn't configured. */ export function getTpagoLink( config: TpagoLinkConfig | null | undefined, ticketCount: number ): string | null { if (!config) return null; const count = Math.min(Math.max(1, Math.floor(ticketCount || 1)), 5); const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig; return config[key] || config.tpagoLink || null; } /** * Spots left for an event, trusting the server's `availableSeats` (which uses * the same seat-holding formula the booking API enforces: paid + claimed * seats count, abandoned pending bookings don't). Falls back to deriving it * from the counts for older API responses. */ export function eventSpotsLeft(event: { capacity: number; bookedCount?: number; claimedCount?: number; availableSeats?: number; }): number { if (typeof event.availableSeats === 'number') { return Math.max(0, event.availableSeats); } return Math.max( 0, (event.capacity ?? 0) - (event.bookedCount ?? 0) - (event.claimedCount ?? 0) ); } export function isEventSoldOut(event: { capacity: number; bookedCount?: number; claimedCount?: number; availableSeats?: number; }): boolean { return eventSpotsLeft(event) <= 0; }