import { Platform } from "react-native"; import { loadToken, saveToken, clearToken, loadOperator, saveOperator, clearOperator } from "./storage"; /** * API base URL. On web the app is served from the same origin as the API, so we * use a relative path. On native (the Android APK) it must point at the public * HTTPS host, baked in at build time via EXPO_PUBLIC_API_URL. */ export const API_BASE = Platform.OS === "web" ? "" : (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, ""); export interface ResourceCount { total: number; redeemed: number; remaining: number; } export interface TicketView { code: string; name: string; email: string; ticketType: string; createdBy: string; total: number; redeemed: number; remaining: number; ice: ResourceCount; adultNames: string[]; extras: { carParking: boolean; rvParking: boolean; utv: boolean; iceAccess: boolean; isDonor: boolean; donorTier: string; vouchers: number; freeUnder5: number; }; ages: { bracket: string; count: number; free: boolean }[]; } export class AuthError extends Error {} export class ApiError extends Error {} let cachedToken: string | null = null; let cachedOperator: string | null = null; export async function getToken(): Promise { if (cachedToken) return cachedToken; cachedToken = await loadToken(); return cachedToken; } export async function getOperator(): Promise { if (cachedOperator !== null) return cachedOperator; cachedOperator = await loadOperator(); return cachedOperator; } export async function setOperator(name: string): Promise { cachedOperator = name; await saveOperator(name); } export async function login(pin: string): Promise { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pin }), }); if (res.status === 401) throw new AuthError("Incorrect PIN"); if (!res.ok) throw new ApiError(`Login failed (${res.status})`); const { token } = (await res.json()) as { token: string }; cachedToken = token; await saveToken(token); } // Lets the auth provider react when the token is cleared (e.g. on a 401), so // UI state stays in sync with storage. let onCleared: (() => void) | null = null; export function onAuthCleared(cb: (() => void) | null): void { onCleared = cb; } export async function logout(): Promise { cachedToken = null; cachedOperator = null; await clearToken(); await clearOperator(); onCleared?.(); } async function authed(path: string, init: RequestInit = {}): Promise { const token = await getToken(); if (!token) throw new AuthError("Not logged in"); const operator = await getOperator(); const res = await fetch(`${API_BASE}${path}`, { ...init, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...(operator ? { "X-Operator": operator } : {}), ...(init.headers || {}), }, }); if (res.status === 401) { await logout(); throw new AuthError("Session expired"); } const text = await res.text(); let body: any = undefined; if (text) { try { body = JSON.parse(text); } catch { body = text; } } if (!res.ok) { throw new ApiError(body?.error ?? body?.detail ?? `Request failed (${res.status})`); } return body as T; } export type LookupResult = | { ok: true; found: true; ticket: TicketView } | { ok: true; found: false } | { ok: false; reason: "db_error"; detail: string }; export function lookup(code: string): Promise { return authed("/api/lookup", { method: "POST", body: JSON.stringify({ code }), }); } export type RedeemResult = | { ok: true; ticket: TicketView; checkedIn: number } | { ok: false; reason: "not_found" | "exhausted" | "insufficient" | "db_error"; ticket?: TicketView; detail?: string; }; export type Resource = "tickets" | "ice"; export function redeem(code: string, count: number, resource: Resource = "tickets"): Promise { return authed("/api/redeem", { method: "POST", body: JSON.stringify({ code, count, resource }), }); } export interface DonorLookup { found: boolean; email: string; name: string; bearName: string; lifetime: number; lastYear: number; online: number; offline: number; isMember: boolean; tags: string[]; status: string; source: "master" | "transactions" | "none"; } export type BanquetResult = | { ok: true; ticketName: string; donor: DonorLookup } | { ok: false; reason: "not_found" | "no_email" | "db_error" | "banquet_disabled"; detail?: string }; export function banquet(input: { code?: string; email?: string }): Promise { return authed("/api/banquet", { method: "POST", body: JSON.stringify(input), }); } export function searchTickets(q: string): Promise<{ results: TicketView[] }> { return authed<{ results: TicketView[] }>(`/api/tickets?q=${encodeURIComponent(q)}`); } export interface AuditEntry { id: number; code: string; people: number; name: string; operator: string; remainingAfter: number; at: string; action: "check-in" | "undo" | "ice" | "ice-undo"; } export interface Stats { orders: number; tickets: { total: number; redeemed: number; remaining: number; pct: number }; people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number }; ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number }; types: { type: string; count: number; total: number; redeemed: number }[]; donors: { orders: number; members: number; vouchers: number }; extras: { carParking: number; rvParking: number; utv: number }; comps: { total: number; byCreator: { name: string; count: number }[] }; operators: { name: string; checkins: number; ice: number; undos: number }[]; checkinsByHour: { hour: string; count: number }[]; generatedAt: string; } export function getStats(force = false): Promise { return authed(`/api/stats${force ? "?force=1" : ""}`); } // Comp-ticket portal (password-gated; separate from the staff PIN). export async function portalVerify(password: string): Promise<{ ok: boolean; types: string[] }> { const res = await fetch(`${API_BASE}/api/portal/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); if (res.status === 401) throw new AuthError("Wrong password"); if (!res.ok) throw new ApiError(`Verify failed (${res.status})`); return res.json(); } export interface PortalTicket { ok: boolean; code: string; type: string; name: string; emailSent: boolean; qr: string; // data URL } export async function portalCreate(input: { password: string; name: string; email: string; type: string; createdBy?: string; }): Promise { const res = await fetch(`${API_BASE}/api/portal/create-ticket`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); if (res.status === 401) throw new AuthError("Wrong password"); const body = await res.json().catch(() => ({})); if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Create failed (${res.status})`); return body; } export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ enabled: boolean; entries: AuditEntry[]; }> { const params = new URLSearchParams(); if (opts.code) params.set("code", opts.code); if (opts.limit) params.set("limit", String(opts.limit)); const qs = params.toString(); return authed<{ enabled: boolean; entries: AuditEntry[] }>(`/api/audit${qs ? `?${qs}` : ""}`); }