Self-service "pay now" flows (registration success + user dashboard) now go straight to a Yoco checkout for the full outstanding balance instead of prompting for a partial amount; that page has been removed. Partial-amount payment links remain supervisor/admin-only via the Payments dashboard. Also adds a "Record Payment" tab to the supervisor manual registration page, pre-filled with the most recently created registration, so staff can capture a payment right after registering someone without leaving the page.
174 lines
6.6 KiB
TypeScript
174 lines
6.6 KiB
TypeScript
export type ApiOptions = {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
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<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
|
const { method = "GET", headers = {}, body, authToken, nextOptions } = options;
|
|
|
|
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
|
|
|
const mergedHeaders: Record<string, string> = { ...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<string, string> = {}): Promise<any[]> {
|
|
const query = new URLSearchParams({ ...params, limit: "200", page: "1" });
|
|
const first = await apiFetch<any>(`${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<any>(`${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<string, string> = {}): Promise<any[]> {
|
|
return fetchAllPages("/api/users", token, params);
|
|
}
|
|
|
|
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
|
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",
|
|
},
|
|
});
|
|
}
|