Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
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);
}