Files
hope-events/frontend/src/app/dashboard/admin/settings/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

510 lines
21 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
const TABS: { id: TabId; label: string }[] = [
{ id: "organisation", label: "Organisation" },
{ id: "branding", label: "Branding" },
{ id: "notifications", label: "Notifications" },
{ id: "email", label: "Email" },
{ id: "legal", label: "Legal" },
];
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
function Field({
label, hint, required, children,
}: {
label: string; hint?: string; required?: boolean; children: React.ReactNode;
}) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
{children}
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
</div>
);
}
function SaveBar({
saving, onSave, result,
}: {
saving: boolean;
onSave: () => void;
result: { ok: boolean; message: string } | null;
}) {
return (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
</span>
) : (
<span />
)}
<button
type="button"
disabled={saving}
onClick={onSave}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
);
}
export default function SiteSettingsPage() {
const { token } = useAuth();
const router = useRouter();
const { reload: reloadSettings } = useSiteSettings();
const [activeTab, setActiveTab] = useState<TabId>("organisation");
const [loadingInitial, setLoadingInitial] = useState(true);
// Per-tab save state
const [saving, setSaving] = useState(false);
const [result, setResult] = useDismissingState<{ ok: boolean; message: string } | null>(null);
// ── Organisation ──
const [orgName, setOrgName] = useState("");
const [orgTagline, setOrgTagline] = useState("");
const [orgEmail, setOrgEmail] = useState("");
const [orgPhone, setOrgPhone] = useState("");
const [orgAddress, setOrgAddress] = useState("");
const [appBaseUrl, setAppBaseUrl] = useState("");
// ── Branding ──
const [accentColor, setAccentColor] = useState("#2563eb");
const [logoUrl, setLogoUrl] = useState("");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// ── Notifications ──
const [notifEmails, setNotifEmails] = useState("");
// ── SMTP ──
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("587");
const [smtpSecure, setSmtpSecure] = useState(false);
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
const [smtpPassSet, setSmtpPassSet] = useState(false);
const [smtpTesting, setSmtpTesting] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useDismissingState<{ ok: boolean; message: string; raw?: string } | null>(null);
// ── Legal ──
const [legalOperatorName, setLegalOperatorName] = useState("");
const [legalIoName, setLegalIoName] = useState("");
const [legalIoEmail, setLegalIoEmail] = useState("");
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
.then((s) => {
setOrgName(s.org_name || "");
setOrgTagline(s.org_tagline || "");
setOrgEmail(s.org_email || "");
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setAccentColor(s.accent_color || "#2563eb");
setLogoUrl(s.logo_url || "");
setNotifEmails(s.reg_notification_emails || "");
setSmtpHost(s.smtp_host || "");
setSmtpPort(s.smtp_port || "587");
setSmtpSecure((s.smtp_secure || "").toLowerCase() === "true");
setSmtpFrom(s.smtp_from || "");
setSmtpUser(s.smtp_user || "");
setSmtpPassSet(!!s.smtp_pass && s.smtp_pass !== "");
setSmtpPass("");
setLegalOperatorName(s.legal_operator_name || "");
setLegalIoName(s.legal_io_name || "");
setLegalIoEmail(s.legal_io_email || "");
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
}, [token]);
// Clear result when switching tabs
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
// ── Save helpers ──────────────────────────────────────────────────────────
const save = async (updates: Record<string, string>) => {
if (!token) return;
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const saveOrganisation = () => save({
org_name: orgName.trim(),
org_tagline: orgTagline.trim(),
org_email: orgEmail.trim(),
org_phone: orgPhone.trim(),
org_address: orgAddress.trim(),
app_base_url: appBaseUrl.trim(),
});
const saveBranding = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
let finalLogoUrl = logoUrl;
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error("Logo upload failed");
const data = await res.json();
finalLogoUrl = data.url || logoUrl;
}
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { accent_color: accentColor, logo_url: finalLogoUrl },
});
setLogoUrl(finalLogoUrl);
setLogoFile(null);
setLogoPreview(null);
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
const saveSmtp = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
const updates: Record<string, string> = {
smtp_host: smtpHost.trim(),
smtp_port: smtpPort.trim(),
smtp_secure: smtpSecure ? "true" : "false",
smtp_from: smtpFrom.trim(),
smtp_user: smtpUser.trim(),
};
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const saveLegal = () => save({
legal_operator_name: legalOperatorName.trim(),
legal_io_name: legalIoName.trim(),
legal_io_email: legalIoEmail.trim(),
legal_website_url: legalWebsiteUrl.trim(),
legal_effective_date: legalEffectiveDate.trim(),
});
const handleTestSmtp = async () => {
if (!token) return;
setSmtpTesting(true);
setSmtpTestResult(null);
try {
await apiFetch("/api/settings/test-smtp", {
method: "POST",
authToken: token,
body: {
host: smtpHost.trim(),
port: smtpPort.trim(),
secure: smtpSecure,
from: smtpFrom.trim(),
user: smtpUser.trim(),
pass: smtpPass.trim() !== "" ? smtpPass.trim() : "••••••••",
},
});
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
} catch (e: any) {
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
} finally {
setSmtpTesting(false);
}
};
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings</div>;
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
return (
<div className="max-w-3xl mx-auto w-full p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Site Settings</h1>
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
</div>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
onClick={() => router.push("/dashboard/admin")}
>
Back
</button>
</div>
{/* Tab bar */}
<div className="flex items-center gap-2 flex-wrap mb-6">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => switchTab(tab.id)}
className={`px-3 py-1.5 text-sm rounded border transition-colors ${
activeTab === tab.id
? "bg-indigo-600 text-white border-indigo-600"
: "bg-white text-gray-800 border-gray-200 hover:bg-gray-50"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── Organisation ─────────────────────────────────────────────────── */}
{activeTab === "organisation" && (
<div className="space-y-4">
<Field label="Organisation name" required>
<input className={inputCls} placeholder="Hope Family Church"
value={orgName} onChange={e => setOrgName(e.target.value)} />
</Field>
<Field label="Tagline">
<input className={inputCls} placeholder="Connecting community through events"
value={orgTagline} onChange={e => setOrgTagline(e.target.value)} />
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="Contact email">
<input type="email" className={inputCls} placeholder="admin@yourchurch.org"
value={orgEmail} onChange={e => setOrgEmail(e.target.value)} />
</Field>
<Field label="Phone">
<input className={inputCls} placeholder="+27 12 345 6789"
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
</Field>
</div>
<Field label="Address">
<input className={inputCls} placeholder="123 Church St, City"
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
</Field>
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div>
)}
{/* ── Branding ─────────────────────────────────────────────────────── */}
{activeTab === "branding" && (
<div className="space-y-5">
<Field label="Accent / brand colour">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
</div>
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
</Field>
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
{currentLogoSrc && (
<div className="mb-3 flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={currentLogoSrc} alt="Current logo" className="h-16 object-contain border rounded p-1 bg-gray-50" />
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
}} className="text-sm" />
</Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} />
</div>
)}
{/* ── Notifications ─────────────────────────────────────────────────── */}
{activeTab === "notifications" && (
<div className="space-y-4">
<Field
label="Registration notification emails"
hint="Who gets notified when someone registers for an event. Separate multiple addresses with commas."
>
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} />
</div>
)}
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
{activeTab === "email" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Outgoing email for tickets, payment confirmations, and account notifications.
Leave blank to use server environment variables. The password is stored encrypted.
</p>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="SMTP host">
<input className={inputCls} placeholder="smtp.yourprovider.com"
value={smtpHost} onChange={e => setSmtpHost(e.target.value)} />
</Field>
<Field label="Port">
<input type="number" className={inputCls} placeholder="587"
value={smtpPort} onChange={e => setSmtpPort(e.target.value)} />
</Field>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="smtpSecure" checked={smtpSecure}
onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
<label htmlFor="smtpSecure" className="text-sm text-gray-700">Use TLS/SSL (port 465)</label>
</div>
<Field label="From address" hint="The address emails appear to come from.">
<input type="email" className={inputCls} placeholder="no-reply@yourchurch.org"
value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} />
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="SMTP username">
<input type="email" className={inputCls} placeholder="mail@yourchurch.org"
value={smtpUser} onChange={e => setSmtpUser(e.target.value)} autoComplete="username" />
</Field>
<Field
label="SMTP password"
hint={smtpPassSet ? "Password is saved. Leave blank to keep it." : undefined}
>
<div className="relative">
<input type="password" className={inputCls}
placeholder={smtpPassSet ? "Leave blank to keep current" : "Enter password"}
value={smtpPass} onChange={e => setSmtpPass(e.target.value)} autoComplete="new-password" />
{smtpPassSet && smtpPass === "" && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-green-600 pointer-events-none"> saved</span>
)}
</div>
</Field>
</div>
{/* Test connection */}
<div className="pt-1 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
disabled={smtpTesting || !smtpHost.trim()}
onClick={handleTestSmtp}
className="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border"
>
{smtpTesting ? "Testing…" : "Test connection"}
</button>
{smtpTestResult && (
<span className={`text-sm ${smtpTestResult.ok ? "text-green-600" : "text-red-600"}`}>
{smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message}
</span>
)}
</div>
{smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && (
<details className="text-xs text-gray-500">
<summary className="cursor-pointer select-none hover:text-gray-700">Show technical details</summary>
<pre className="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto whitespace-pre-wrap break-all">{smtpTestResult.raw}</pre>
</details>
)}
</div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} />
</div>
)}
{/* ── Legal ─────────────────────────────────────────────────────────── */}
{activeTab === "legal" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
These values populate the Terms of Use and Privacy Policy pages automatically.
</p>
<Field label="Operator / responsible party"
hint='Shown in the "Owned and operated by" line of the Terms of Use.'>
<input className={inputCls} placeholder="Jane Smith on behalf of Example Church, City, South Africa"
value={legalOperatorName} onChange={e => setLegalOperatorName(e.target.value)} />
</Field>
<Field label="Website URL (without https://)">
<input className={inputCls} placeholder="events.yourchurch.org"
value={legalWebsiteUrl} onChange={e => setLegalWebsiteUrl(e.target.value)} />
</Field>
<Field label="Effective date">
<input className={inputCls} placeholder="April 2026"
value={legalEffectiveDate} onChange={e => setLegalEffectiveDate(e.target.value)} />
</Field>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Information Officer (POPIA)</p>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="Full name">
<input className={inputCls} placeholder="Jane Smith"
value={legalIoName} onChange={e => setLegalIoName(e.target.value)} />
</Field>
<Field label="Email">
<input type="email" className={inputCls} placeholder="io@yourchurch.org"
value={legalIoEmail} onChange={e => setLegalIoEmail(e.target.value)} />
</Field>
</div>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
</div>
);
}