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