export type ApiOptions = { method?: string; headers?: Record; body?: any; authToken?: string | null; nextOptions?: RequestInit; }; // Compute a robust API base that works locally, across devices, and in prod. // Priority: NEXT_PUBLIC_API_URL -> API_URL -> (browser) smart dev fallbacks -> (browser) window.origin -> "" function computeApiBase(): string { const envBase = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || ""; // If running in the browser, try to adapt localhost to current hostname for LAN/mobile testing if (typeof window !== "undefined") { try { if (envBase) { const u = new URL(envBase); const isLocalHost = ["localhost", "127.0.0.1", "::1"].includes(u.hostname); if (isLocalHost) { // Replace localhost with the actual device hostname so other devices can reach the dev machine const protocol = u.protocol || window.location.protocol; const hostname = window.location.hostname; // e.g., 192.168.x.x or your.dev.domain const port = u.port ? `:${u.port}` : ""; return `${protocol}//${hostname}${port}`; } return u.origin; } // No env base provided — provide a sensible dev fallback // If Next dev server is on 3000, assume backend on the same host port 5000 if (window.location.port === "3000") { const protocol = window.location.protocol; const hostname = window.location.hostname; return `${protocol}//${hostname}:5000`; } // Otherwise, assume same origin will proxy /api (works if reverse proxy is configured) return window.location.origin; } catch { // Fallbacks below } } // On the server (or if window not available), use envBase if present; otherwise empty string. return envBase || ""; } export const API_BASE = computeApiBase(); // Resolve a URL or path to use the API origin. If it's a relative path, prefix with API_BASE. // If it's an absolute URL but points to localhost/127.0.0.1/::1, rewrite its origin to API_BASE. export function resolveToApiOrigin(urlOrPath: string | null | undefined): string | null { const raw = (urlOrPath || "").trim(); if (!raw) return null; try { if (raw.startsWith("http")) { const u = new URL(raw); const isLocal = ["localhost", "127.0.0.1", "::1"].includes(u.hostname); if (isLocal && API_BASE) { const api = new URL(API_BASE); u.protocol = api.protocol; u.hostname = api.hostname; u.port = api.port; return u.toString(); } return raw; // already absolute and not localhost } } catch { // fall through to relative handling } return API_BASE ? `${API_BASE}${raw}` : raw; } export class ApiError extends Error { constructor(public readonly status: number, message: string, public readonly data?: any) { super(message); this.name = "ApiError"; } } export async function apiFetch(path: string, options: ApiOptions = {}): Promise { const { method = "GET", headers = {}, body, authToken, nextOptions } = options; const url = path.startsWith("http") ? path : `${API_BASE}${path}`; const mergedHeaders: Record = { ...headers }; if (!(body instanceof FormData)) { mergedHeaders["Content-Type"] = "application/json"; } if (authToken) { mergedHeaders["Authorization"] = `Bearer ${authToken}`; } const res = await fetch(url, { method, headers: mergedHeaders as HeadersInit, body: body instanceof FormData ? body : body != null ? JSON.stringify(body) : undefined, ...nextOptions, } as RequestInit); if (!res.ok) { const text = await res.text().catch(() => ""); let message = text || `Request failed with status ${res.status}`; let data: any; // Backend returns { message: "..." } — extract it so callers get a plain string try { const json = JSON.parse(text); if (json?.message && typeof json.message === "string") message = json.message; data = json; } catch { /* not JSON — use raw text */ } throw new ApiError(res.status, message, data); } const contentType = res.headers.get("content-type") || ""; if (contentType.includes("application/json")) { return res.json(); } // @ts-ignore return res.text(); } // Several list endpoints (/api/users, /api/payments) are paginated and cap // `limit` at 200 server-side, so a single request can silently miss records // beyond the first page. This walks every page and returns the full list. async function fetchAllPages(path: string, token: string, params: Record = {}): Promise { const query = new URLSearchParams({ ...params, limit: "200", page: "1" }); const first = await apiFetch(`${path}?${query.toString()}`, { authToken: token }); const items: any[] = Array.isArray(first?.data) ? first.data : []; const pages = first?.pages || 1; if (pages > 1) { // Page 1 told us the total page count — fetch the rest concurrently instead of // awaiting them one at a time. This function is called on every dashboard poll // (see useVisiblePolling usages), so a serial loop here directly slows those polls. const remaining = await Promise.all( Array.from({ length: pages - 1 }, (_, i) => { const page = i + 2; const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) }); return apiFetch(`${path}?${pageQuery.toString()}`, { authToken: token }); }) ); for (const res of remaining) { if (Array.isArray(res?.data)) items.push(...res.data); } } return items; } export function fetchAllUsers(token: string, params: Record = {}): Promise { return fetchAllPages("/api/users", token, params); } export function fetchAllPayments(token: string, params: Record = {}): Promise { return fetchAllPages("/api/payments", token, params); } // Creates a Yoco checkout for a registration's full remaining outstanding balance. // Omitting `amount` makes the backend charge the full remainder rather than a partial amount — // partial-amount checkouts are only ever created from the supervisor payments dashboard. export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> { return apiFetch("/api/payments/yoco-checkout", { method: "POST", authToken: token, body: { registrationId, successUrl: window.location.origin + "/payment/success", cancelUrl: window.location.origin + "/payment/cancel", failureUrl: window.location.origin + "/payment/failure", }, }); }