import type { UserTicket, Event } from '@/lib/api'; import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; // Hours a booking holds a seat before the spot is released. There is no // server-side enforcement field, so the countdown is derived from when the // ticket was created, capped at the event start time. export const PAYMENT_HOLD_HOURS = 24; /** Currency is Guarani with no decimals, e.g. "21 PYG". */ export function pyg(amount: number, currency: string = 'PYG'): string { return formatPrice(Number(amount) || 0, currency || 'PYG'); } export function isManualProvider(provider?: string): boolean { return provider === 'bank_transfer' || provider === 'tpago'; } /** A ticket is unpaid when its payment is still pending (not approved/paid). */ export function isUnpaid(ticket: Pick & { payment?: { status?: string } | null }): boolean { const ps = ticket.payment?.status; return ticket.status !== 'cancelled' && (ps === 'pending' || ps === 'failed'); } export function isAwaitingApproval(ticket: { payment?: { status?: string } | null }): boolean { return ticket.payment?.status === 'pending_approval'; } /** * The moment the seat hold expires. null when paid/awaiting/cancelled or when * the data needed to compute it is missing. */ export function holdExpiry(ticket: UserTicket): Date | null { if (!isUnpaid(ticket)) return null; const createdStr = ticket.payment?.createdAt || ticket.createdAt; if (!createdStr) return null; const created = parseDate(createdStr); let expiry = new Date(created.getTime() + PAYMENT_HOLD_HOURS * 60 * 60 * 1000); // Never hold past the event itself. const eventStart = ticket.event?.startDatetime ? parseDate(ticket.event.startDatetime) : null; if (eventStart && eventStart < expiry) expiry = eventStart; return expiry; } /** Amount owed for a ticket (payment amount preferred, falls back to event price). */ export function ticketAmount(ticket: UserTicket): { amount: number; currency: string } { const amount = ticket.payment?.amount ?? ticket.event?.price ?? 0; const currency = ticket.payment?.currency ?? ticket.event?.currency ?? 'PYG'; return { amount: Number(amount) || 0, currency }; } export interface BookingGroup { bookingId: string; tickets: UserTicket[]; } /** * Group tickets that belong to the same booking (multi-ticket / guest tickets). * Tickets without a bookingId become single-ticket groups keyed by their id. * Order of first appearance is preserved. */ export function groupByBooking(tickets: UserTicket[]): BookingGroup[] { const groups = new Map(); const order: string[] = []; for (const ticket of tickets) { const key = ticket.bookingId || ticket.id; if (!groups.has(key)) { groups.set(key, []); order.push(key); } groups.get(key)!.push(ticket); } return order.map((bookingId) => ({ bookingId, tickets: groups.get(bookingId)! })); } /** Public URL embedded in the scannable QR (matches the PDF + scanner contract). */ export function ticketScanUrl(ticketId: string): string { const origin = typeof window !== 'undefined' ? window.location.origin : ''; return `${origin}/ticket/${ticketId}`; } /** Download URL for a ticket / whole booking PDF. */ export function ticketPdfUrl(ticket: UserTicket): string { return ticket.bookingId ? `/api/tickets/booking/${ticket.bookingId}/pdf` : `/api/tickets/${ticket.id}/pdf`; } /** Build a Google Calendar "add event" URL — no backend needed. */ export function googleCalendarUrl(event: Event, locale: string): string { const start = parseDate(event.startDatetime); const end = event.endDatetime ? parseDate(event.endDatetime) : new Date(start.getTime() + 2 * 60 * 60 * 1000); const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const title = (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Spanglish'; const params = new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${fmt(start)}/${fmt(end)}`, location: event.location || '', ctz: EVENT_TIMEZONE, }); return `https://www.google.com/calendar/render?${params.toString()}`; } /** * Share a single (spare) ticket's QR with a guest. Uses the Web Share API when * available, otherwise copies the ticket link to the clipboard. */ export async function shareTicket( ticketId: string, eventTitle: string, locale: string ): Promise<'shared' | 'copied' | 'failed'> { const url = ticketScanUrl(ticketId); const text = locale === 'es' ? `Tu entrada para ${eventTitle}` : `Your ticket for ${eventTitle}`; try { if (typeof navigator !== 'undefined' && (navigator as any).share) { await (navigator as any).share({ title: eventTitle, text, url }); return 'shared'; } if (typeof navigator !== 'undefined' && navigator.clipboard) { await navigator.clipboard.writeText(url); return 'copied'; } return 'failed'; } catch (err: any) { // User cancelled the native share sheet. if (err?.name === 'AbortError') return 'shared'; return 'failed'; } } /** True when the event's calendar date is today (Asunción time). */ export function isToday(dateStr?: string): boolean { if (!dateStr) return false; const opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: EVENT_TIMEZONE, }; const fmt = (d: Date) => d.toLocaleDateString('en-CA', opts); return fmt(parseDate(dateStr)) === fmt(new Date()); }