Consolidate profile and security into AccountTab, add shared dashboard components, and move awaiting-approval payment messages to translations. Co-authored-by: Cursor <cursoragent@cursor.com>
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
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<UserTicket, 'status'> & { 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<string, UserTicket[]>();
|
|
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());
|
|
}
|