Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user