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);
}
+25
View File
@@ -0,0 +1,25 @@
export type AuthUser = {
id: string;
name: string;
email: string;
role?: "admin" | "supervisor" | "staff" | "user";
phoneNumber?: string | null;
notificationPreference?: "email" | "whatsapp" | "both";
};
export type AuthResponse = AuthUser & {
token: string;
};
const TOKEN_KEY = "hope_events_token";
export function saveToken(token: string) {
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token);
}
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function clearToken() {
if (typeof window !== "undefined") localStorage.removeItem(TOKEN_KEY);
}
+37
View File
@@ -0,0 +1,37 @@
export function formatDate(input: string | number | Date | null | undefined): string {
if (input == null) return "";
const d = new Date(input);
if (isNaN(d.getTime())) return "";
// Use en-GB to ensure day-month-year with full month name, e.g., 20 March 2025
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
}).format(d);
}
export function formatDateTime(input: string | number | Date | null | undefined): string {
if (input == null) return "";
const d = new Date(input);
if (isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(d);
}
export function formatDateTimeRange(
start: string | number | Date | null | undefined,
end: string | number | Date | null | undefined
): string {
const startStr = formatDateTime(start);
const endStr = formatDateTime(end);
if (startStr && endStr) return `${startStr}${endStr}`;
if (startStr) return startStr;
if (endStr) return endStr;
return "";
}
+9
View File
@@ -0,0 +1,9 @@
// Cheap structural equality for small JSON-shaped payloads (dashboard stats, not large lists).
export function jsonEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
+113
View File
@@ -0,0 +1,113 @@
export type TableRow = Record<string, any>;
export function downloadCsv(filename: string, rows: TableRow[], columns?: { key: string; label?: string }[]) {
const cols = columns && columns.length > 0 ? columns : Object.keys(rows[0] || {}).map(k => ({ key: k, label: k }));
const header = cols.map(c => escapeCsv(c.label || c.key)).join(',');
const lines = rows.map(r => cols.map(c => escapeCsv(valueToString(r[c.key]))).join(','));
const csv = [header, ...lines].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = ensureCsvExt(filename);
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
function ensureCsvExt(name: string) {
return name.toLowerCase().endsWith('.csv') ? name : name + '.csv';
}
function escapeCsv(v: string) {
if (v == null) return '';
if (/[",\n]/.test(v)) return '"' + v.replace(/"/g, '""') + '"';
return v;
}
function valueToString(v: any): string {
if (v == null) return '';
if (v instanceof Date) return v.toISOString();
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
// New: server-side PDF generation and email helpers
export type ReportPdfPayload = {
title: string;
kind: 'table' | 'layered';
orientation?: 'portrait' | 'landscape';
table?: { columns: string[]; rows: (string | number)[][] };
layered?: { header?: string; sections: { title: string; items: string[] }[] };
};
export async function downloadReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
const url = `${apiBase}/api/reports/pdf`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to generate PDF (${res.status})`);
}
const blob = await res.blob();
const dl = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
dl.href = objUrl;
const safe = (payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase();
dl.download = `${safe}.pdf`;
dl.click();
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
export async function emailReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { subject?: string; body?: string }) {
const url = `${apiBase}/api/reports/email`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to email PDF (${res.status})`);
}
return res.json();
}
// Legacy (used elsewhere). Kept in case other code paths still rely on print flow.
export function openPrintWindow(title: string, htmlContent: string) {
const w = window.open('', '_blank');
if (!w) return;
w.document.write(`<!doctype html><html><head><title>${title}</title>
<style>
@page { size: A4; margin: 12mm; }
html, body { height: auto; }
body{font-family: Arial, Helvetica, sans-serif; padding:16px}
table{border-collapse: collapse; width: 100%;}
th, td{border:1px solid #e5e7eb; padding:6px 8px; font-size:12px;}
th{background:#f3f4f6; text-align:left}
h2{margin:0 0 12px 0}
.note{color:#6b7280; font-size:12px; margin-bottom:8px}
*{ box-sizing: border-box; }
.section{ page-break-inside: avoid; margin-bottom: 10px; }
@media print{ .no-print{ display:none !important } }
</style>
</head><body>
<h2>${title}</h2>
${htmlContent}
</body></html>`);
w.document.close();
w.focus();
}
export function mailtoReport(subject: string, body: string) {
const url = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = url;
}
+51
View File
@@ -0,0 +1,51 @@
/** Levenshtein edit distance between two strings */
export function editDistance(a: string, b: string): number {
const m = a.length, n = b.length;
const dp: number[] = Array(n + 1).fill(0).map((_, j) => j);
for (let i = 1; i <= m; i++) {
let prev = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
const temp = dp[j];
dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
prev = temp;
}
}
return dp[n];
}
/** Return a score 01 for how well a user matches a search query */
export function scoreUser(u: any, raw: string): number {
const q = raw.toLowerCase().trim();
if (!q || q.length < 2) return 0;
const name = (u.name || "").toLowerCase();
const email = (u.email || "").toLowerCase();
const phone = (u.phoneNumber || "").replace(/\D/g, "");
const qPhone = q.replace(/\D/g, "");
let best = 0;
// Exact substring matches — highest priority
if (name.includes(q)) best = Math.max(best, 1.0);
if (email.includes(q)) best = Math.max(best, 0.95);
if (qPhone.length >= 3 && phone.includes(qPhone)) best = Math.max(best, 0.95);
// Word-level starts-with (handles partial first/last name)
const words = name.split(/\s+/);
if (words.some((w: string) => w.startsWith(q))) best = Math.max(best, 0.85);
// Fuzzy edit-distance against each name word
const maxDist = q.length <= 4 ? 1 : q.length <= 7 ? 2 : 3;
for (const w of words) {
const slice = w.slice(0, q.length + 2); // compare against similar-length slice
const d = editDistance(q, slice);
if (d <= maxDist) best = Math.max(best, 0.75 - d * 0.15);
}
// Also fuzzy against the full name (catches "jhn smth" → "john smith")
const nameSlice = name.slice(0, q.length + 4);
const fullDist = editDistance(q, nameSlice);
if (fullDist <= maxDist + 1) best = Math.max(best, 0.6 - fullDist * 0.12);
return best;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* South African phone number utilities (mirrors backend/src/utils/whatsapp.js logic).
*/
/**
* Normalise a raw phone input to the international SA format (27XXXXXXXXX).
* Returns null if the input is not a valid SA mobile number.
*/
export function normalizeZAPhone(raw: string | null | undefined): string | null {
if (!raw) return null;
let digits = raw.replace(/\D/g, '');
if (digits.startsWith('0') && digits.length === 10) digits = '27' + digits.slice(1);
if (/^27\d{9}$/.test(digits)) return digits;
return null;
}
/**
* Returns true when the value is a valid SA mobile number (any common format).
*/
export function isValidZAPhone(raw: string | null | undefined): boolean {
return normalizeZAPhone(raw) !== null;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Site-wide configuration sourced from environment variables.
* Set these in your .env file (NEXT_PUBLIC_ prefix required for browser access).
*/
export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events';
export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Hope Family Church';
export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@hopehenley.co.za';
export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://events.hopehenley.co.za';
export const brandColor = process.env.NEXT_PUBLIC_BRAND_COLOR || '#2563eb';
+27
View File
@@ -0,0 +1,27 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { format, isValid } from "date-fns"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date): string {
if (!isValid(date)) {
return "Invalid date"
}
return format(date, "MMMM d, yyyy")
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-ZA", {
style: "currency",
currency: "ZAR",
minimumFractionDigits: 2,
}).format(amount)
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return `${text.slice(0, maxLength)}...`
}