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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 0–1 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)}...`
|
||||
}
|
||||
Reference in New Issue
Block a user