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
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { resolveToApiOrigin } from "@/lib/api";
type Props = {
src?: string | null;
alt?: string;
className?: string;
style?: React.CSSProperties;
};
export function ApiImage({ src, alt = "", className, style }: Props) {
const hasSrc = !!(src || "").trim();
const [resolved, setResolved] = React.useState<string | null>(null);
React.useEffect(() => {
const raw = (src || "").trim();
if (!raw) { setResolved(null); return; }
// Compute in the browser so that localhost origins are swapped to the actual device hostname.
// This has to stay in an effect (not computed during render) because the server-rendered pass
// has no `window`, so resolving synchronously would make the client's first render disagree
// with the server-rendered HTML (a hydration mismatch) — the round trip here is a browser-vs-
// server naming difference, not a network fetch.
const url = resolveToApiOrigin(raw);
setResolved(url);
}, [src]);
// No image was ever provided for this item — render nothing, same as before.
if (!hasSrc) return null;
// A src was provided but the browser-resolved URL isn't ready yet — reserve the same box the
// real <img> will occupy so the surrounding layout (e.g. a card) doesn't collapse to zero height
// and then jump once resolved — same class of flash as an unauthenticated nav link showing too early.
if (!resolved) return <div className={`${className || ""} bg-gray-100 animate-pulse`} style={style} aria-hidden="true" />;
return <img src={resolved} alt={alt} className={className} style={style} />;
}
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type BannerType = "info" | "warning" | "success" | "danger";
interface Banner {
message: string;
type: BannerType;
liveFrom: string | null;
liveTill: string | null;
}
const STYLES: Record<BannerType, string> = {
info: "bg-blue-600 text-white",
warning: "bg-amber-400 text-amber-950",
success: "bg-emerald-600 text-white",
danger: "bg-red-600 text-white",
};
function dismissKey(banner: Banner) {
return `banner_dismissed_${banner.message}_${banner.liveFrom}_${banner.liveTill}`;
}
export function BannerBar() {
const [banner, setBanner] = useState<Banner | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
apiFetch<Banner>("/api/banner")
.then(b => {
if (!b?.message) return;
setBanner(b);
const key = dismissKey(b);
if (typeof window !== "undefined" && localStorage.getItem(key) === "1") {
setDismissed(true);
}
})
.catch(() => {});
}, []);
if (!banner || !banner.message || dismissed) return null;
const now = Date.now();
const from = banner.liveFrom ? new Date(banner.liveFrom).getTime() : null;
const till = banner.liveTill ? new Date(banner.liveTill).getTime() : null;
if (from !== null && now < from) return null;
if (till !== null && now > till) return null;
const dismiss = () => {
if (typeof window !== "undefined") {
localStorage.setItem(dismissKey(banner), "1");
}
setDismissed(true);
};
return (
<div className={`w-full px-4 py-2.5 flex items-center justify-center gap-3 text-sm font-medium ${STYLES[banner.type ?? "info"]}`}>
<span className="flex-1 text-center">{banner.message}</span>
<button
onClick={dismiss}
aria-label="Dismiss banner"
className="shrink-0 opacity-70 hover:opacity-100 transition-opacity text-lg leading-none"
>
×
</button>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline" | "danger";
loading?: boolean;
};
export function Button({
variant = "primary",
loading = false,
className = "",
children,
disabled,
...rest
}: ButtonProps) {
const base = "inline-flex items-center justify-center rounded px-4 py-2 text-sm transition-colors";
const variants: Record<string, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-800 text-white hover:bg-black/90",
outline: "border border-gray-300 text-gray-800 hover:bg-gray-50",
danger: "bg-red-600 text-white hover:bg-red-700",
};
const cls = [base, variants[variant], disabled || loading ? "opacity-60 cursor-not-allowed" : "", className]
.filter(Boolean)
.join(" ");
return (
<button className={cls} disabled={disabled || loading} {...rest}>
{loading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
)}
{children}
</button>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { SectionHeader } from "@/components/shared/SectionHeader";
export function ContactSection() {
const { settings } = useSiteSettings();
const email = settings.org_email || "";
const phone = settings.org_phone || "";
const address = settings.org_address || "";
if (!email && !phone && !address) return null;
return (
<section id="contact" className="bg-gray-100 py-12 px-4 text-center">
<SectionHeader title="Contact Us" />
{email && (
<p className="text-gray-700 mb-2">
📧{" "}
<a href={`mailto:${email}`} className="hover:underline">
{email}
</a>
</p>
)}
{phone && (
<p className="text-gray-700 mb-2">
📞{" "}
<a href={`tel:${phone}`} className="hover:underline">
{phone}
</a>
</p>
)}
{address && (
<p className="text-gray-700">
📍 {address}
</p>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
export function Loader({ label = "Loading...", className = "" }: { label?: string; className?: string }) {
return (
<div className={["flex items-center space-x-2 text-gray-600", className].filter(Boolean).join(" ")}>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span className="text-sm">{label}</span>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import React from "react";
import QRCode from "qrcode";
type Props = {
value: string;
size?: number;
alt?: string;
className?: string;
};
/**
* On-screen QR preview generated locally (via the `qrcode` package) instead of hitting
* api.qrserver.com — removes a third-party network dependency from ticket views that are
* viewed frequently (dashboard, event-tickets, at-the-door).
*/
export function QrImage({ value, size = 120, alt = "QR", className }: Props) {
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
QRCode.toDataURL(value, { width: size, margin: 1 })
.then((url) => { if (!cancelled) setDataUrl(url); })
.catch(() => { if (!cancelled) setDataUrl(null); });
return () => { cancelled = true; };
}, [value, size]);
if (!dataUrl) return <div className={`${className || ""} bg-gray-100 animate-pulse`} aria-hidden="true" />;
return <img src={dataUrl} alt={alt} className={className} />;
}
@@ -0,0 +1,20 @@
import React from "react";
export type Role = "user" | "staff" | "supervisor" | "admin";
export function RoleBadge({ role, className = "" }: { role: Role; className?: string }) {
const colors: Record<Role, string> = {
user: "bg-gray-100 text-gray-800",
staff: "bg-indigo-100 text-indigo-800",
supervisor: "bg-emerald-100 text-emerald-800",
admin: "bg-red-100 text-red-800",
};
return (
<span className={["inline-flex items-center rounded px-2 py-0.5 text-xs font-medium", colors[role], className]
.filter(Boolean)
.join(" ")}
>
{role}
</span>
);
}
@@ -0,0 +1,3 @@
export const SectionHeader = ({ title }: { title: string }) => (
<h2 className="text-2xl font-semibold mb-6 text-center">{title}</h2>
);
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
import { apiFetch } from "@/lib/api";
// Checks once per browser session whether first-time setup is needed.
// If the backend says yes and we're not already on /setup, redirect there.
export function SetupGuard() {
const router = useRouter();
const pathname = usePathname();
const checked = useRef(false);
useEffect(() => {
if (checked.current) return;
if (pathname?.startsWith("/setup")) return;
checked.current = true;
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
.then((data) => {
if (data?.needsSetup) {
router.replace("/setup");
}
})
.catch(() => {/* API down — don't block */});
}, [pathname]);
return null;
}
@@ -0,0 +1,52 @@
"use client";
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
export type Toast = { id: string; message: string; type?: "info" | "success" | "error" };
type ToastContextValue = {
show: (message: string, type?: Toast["type"]) => void;
};
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const show = useCallback((message: string, type: Toast["type"] = "info") => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, message, type }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500);
}, []);
const value = useMemo(() => ({ show }), [show]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed inset-x-0 top-2 z-[100] flex flex-col items-center space-y-2">
{toasts.map((t) => (
<div
key={t.id}
className={[
"rounded px-3 py-2 text-sm shadow",
t.type === "success" && "bg-green-600 text-white",
t.type === "error" && "bg-red-600 text-white",
(!t.type || t.type === "info") && "bg-gray-800 text-white",
]
.filter(Boolean)
.join(" ")}
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}