Files
Spanglish/frontend/src/lib/utils.ts
T
MichilisandClaude Fable 5 71c277045b Centralize seat capacity accounting and the payment provider registry.
Replace manualProviders.ts with a paymentProviders.ts registry
(automatic vs manual settlement) and move all seat counting into
capacity.ts as the single source of truth: only paid/checked-in tickets
and pending_approval payments hold a seat, so abandoned checkouts never
block sales. Admins can now knowingly approve a payment over capacity
(allowOverCapacity), with the booking and admin UIs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:40 +00:00

238 lines
7.3 KiB
TypeScript

// ---------------------------------------------------------------------------
// 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;
}