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; 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 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}` : ""}`); }