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>
238 lines
7.3 KiB
TypeScript
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;
|
|
}
|