Full site redesign, help system, and dashboard stats fixes

Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:00:10 +02:00
co-authored by Claude Sonnet 5
parent d74fec3a5c
commit 8e6cb542d9
119 changed files with 5116 additions and 4316 deletions
+228 -199
View File
@@ -6,6 +6,15 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
password_changed: { label: () => "Password changed", icon: KeyRound },
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
};
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
@@ -91,6 +100,7 @@ export default function UserProfilePage() {
setNewPassword("");
setConfirmPassword("");
setPwMsg({ type: "ok", text: "Password changed." });
loadActivity();
} catch (e: any) {
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
} finally {
@@ -123,6 +133,25 @@ export default function UserProfilePage() {
}
};
// ── Account activity ──────────────────────────────────────────────────────
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [loadingActivity, setLoadingActivity] = useState(false);
const loadActivity = async () => {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
setActivity(Array.isArray(res) ? res : []);
} catch (e) {
// Non-critical — just leave the list empty rather than showing an error banner.
} finally {
setLoadingActivity(false);
}
};
useEffect(() => { loadActivity(); }, [token]);
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
@@ -153,214 +182,214 @@ export default function UserProfilePage() {
// ── Shared helpers ────────────────────────────────────────────────────────
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
<div className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{msg.text}
</div>
);
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const SectionHeader = ({ icon: Icon, title, tone = "brand" }: { icon: typeof User; title: string; tone?: "brand" | "amber" | "red" }) => {
const toneCls = tone === "amber" ? "bg-amber-50 text-amber-600" : tone === "red" ? "bg-red-50 text-red-600" : "bg-brand-50 text-brand-600";
return (
<div className="flex items-center gap-2 mb-4">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${toneCls}`}>
<Icon className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
);
};
return (
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
<h1 className="text-2xl font-semibold">Profile &amp; Security</h1>
<div className="max-w-4xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Profile &amp; Security</h1>
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
</div>
{/* ── Profile info ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="e.g. 082 123 4567"
/>
</div>
<div className="grid lg:grid-cols-2 gap-6 items-start">
<div className="space-y-6">
{/* ── Profile info ─────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={User} title="Personal information" />
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={notifPref}
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
<button
type="submit"
disabled={savingProfile}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingProfile ? "Saving…" : "Save changes"}
</button>
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Password ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Change password</h2>
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={savingPw}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingPw ? "Changing…" : "Change password"}
</button>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-1">Security</h2>
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
<button type="submit" disabled={savingProfile} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingProfile ? "Saving…" : "Save changes"}
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Account activity ─────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Clock} title="Account activity" />
{loadingActivity && activity.length === 0 ? (
<p className="text-sm text-gray-400">Loading</p>
) : activity.length === 0 ? (
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
) : (
<ul className="space-y-3">
{activity.map(ev => {
const meta = ACTIVITY_META[ev.type] || ACTIVITY_META.login;
const Icon = meta.icon;
return (
<li key={ev.id} className="flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center shrink-0 mt-0.5">
<Icon className="w-3.5 h-3.5 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
<div className="space-y-6">
{/* ── Password ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Lock} title="Change password" />
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
</div>
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingPw ? "Changing…" : "Change password"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Trash2} title="Close account" tone="red" />
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
</div>
</div>
</div>
);
}
}