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
+32 -32
View File
@@ -631,7 +631,7 @@ export default function SelfServicePage() {
type={showChangePassword ? "text" : "password"}
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="Your password"
autoFocus
required
@@ -651,7 +651,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={changeLoading}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 disabled:opacity-60 transition"
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 disabled:opacity-60 transition"
>
{changeLoading ? "Verifying…" : "Confirm"}
</button>
@@ -678,7 +678,7 @@ export default function SelfServicePage() {
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="supervisor@example.com"
required
autoComplete="email"
@@ -691,7 +691,7 @@ export default function SelfServicePage() {
type={showSetupPassword ? "text" : "password"}
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="••••••••"
required
autoComplete="current-password"
@@ -703,7 +703,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={setupLoading}
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-60 transition mt-2"
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
>
{setupLoading ? "Signing in…" : "Sign In"}
</button>
@@ -723,7 +723,7 @@ export default function SelfServicePage() {
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value=""> Select an event </option>
{events.map((ev) => (
@@ -735,9 +735,9 @@ export default function SelfServicePage() {
</div>
)}
{selectedEvent && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-sm text-brand-800">
<p className="font-semibold">{selectedEvent.title}</p>
<p className="text-blue-600">{fmtDate(selectedEvent.startDate)} {fmtDate(selectedEvent.endDate)}</p>
<p className="text-brand-600">{fmtDate(selectedEvent.startDate)} {fmtDate(selectedEvent.endDate)}</p>
{selectedEvent.form?.isRequired && (
<p className="text-orange-600 mt-1 text-xs font-medium">This event requires an attendee form to be completed at registration.</p>
)}
@@ -746,7 +746,7 @@ export default function SelfServicePage() {
<button
onClick={handleStartKiosk}
disabled={!selectedEventId}
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-50 transition"
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-50 transition"
>
Start Kiosk
</button>
@@ -784,7 +784,7 @@ export default function SelfServicePage() {
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
}}
onBlur={handleEmailBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="john@example.com"
/>
</div>
@@ -804,7 +804,7 @@ export default function SelfServicePage() {
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
}}
onBlur={handlePhoneBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="+27 82 000 0000"
/>
</div>
@@ -831,7 +831,7 @@ export default function SelfServicePage() {
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="John Smith"
required
/>
@@ -850,7 +850,7 @@ export default function SelfServicePage() {
className={`flex-1 py-3 transition-colors ${
notificationPref === p
? p === "whatsapp" ? "bg-green-600 text-white"
: p === "both" ? "bg-indigo-600 text-white"
: p === "both" ? "bg-brand-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
@@ -901,7 +901,7 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
>
+
</button>
@@ -932,7 +932,7 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
>
+
</button>
@@ -959,7 +959,7 @@ export default function SelfServicePage() {
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setCreateAccount((v) => !v)}
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-blue-600" : "bg-gray-300"}`}
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-300"}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
</div>
@@ -976,7 +976,7 @@ export default function SelfServicePage() {
type={showVisitorPassword ? "text" : "password"}
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
@@ -996,7 +996,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={formLoading || accountConflict}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
>
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
</button>
@@ -1033,7 +1033,7 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 transition"
>
Confirm &amp; Update
</button>
@@ -1051,7 +1051,7 @@ export default function SelfServicePage() {
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
{mainTicketCount > 1 && (
<div className="mt-3">
<p className="text-base font-semibold text-blue-700">
<p className="text-base font-semibold text-brand-700">
Attendee {currentTicketIndex + 1} of {mainTicketCount}
</p>
<div className="flex gap-1.5 justify-center mt-2">
@@ -1062,7 +1062,7 @@ export default function SelfServicePage() {
i < currentTicketIndex
? "w-6 bg-green-500"
: i === currentTicketIndex
? "w-6 bg-blue-600"
? "w-6 bg-brand-600"
: "w-6 bg-gray-200"
}`}
/>
@@ -1096,7 +1096,7 @@ export default function SelfServicePage() {
checked={currentTicketAnswers[field.id] === opt}
onChange={() => setCurrentTicketAnswers(a => ({ ...a, [field.id]: opt }))}
required={field.isRequired && !currentTicketAnswers[field.id]}
className="accent-blue-600"
className="accent-brand-600"
/>
<span className="text-base">{opt}</span>
</label>
@@ -1108,7 +1108,7 @@ export default function SelfServicePage() {
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
) : field.type === "date" ? (
<input
@@ -1116,7 +1116,7 @@ export default function SelfServicePage() {
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
) : field.type === "paragraph" ? (
<textarea
@@ -1124,7 +1124,7 @@ export default function SelfServicePage() {
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
rows={3}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
/>
) : (
<input
@@ -1132,7 +1132,7 @@ export default function SelfServicePage() {
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
)}
</div>
@@ -1147,7 +1147,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={formSubmitLoading}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
>
{formSubmitLoading
? "Submitting…"
@@ -1175,7 +1175,7 @@ export default function SelfServicePage() {
<p className="text-lg text-gray-600 mb-3">Welcome, <span className="font-semibold">{confirmationName}</span>!</p>
)}
{selectedEvent && (
<p className="text-blue-700 font-medium mb-4">{selectedEvent.title}</p>
<p className="text-brand-700 font-medium mb-4">{selectedEvent.title}</p>
)}
{registrationIsFree ? (
@@ -1189,7 +1189,7 @@ export default function SelfServicePage() {
</p>
</div>
) : (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-blue-800 text-sm mb-5 text-left">
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-brand-800 text-sm mb-5 text-left">
<p className="font-semibold mb-1">
{notificationPref === "whatsapp"
? "Payment instructions have been sent to your WhatsApp."
@@ -1197,12 +1197,12 @@ export default function SelfServicePage() {
? "Payment instructions have been sent via email and WhatsApp."
: "Payment instructions have been emailed to you."}
</p>
<p className="text-blue-700">Payment can also be made at the door cash and card accepted.</p>
<p className="text-brand-700">Payment can also be made at the door cash and card accepted.</p>
</div>
)}
<div className="mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-blue-200 bg-blue-50 text-2xl font-bold text-blue-700">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-brand-200 bg-brand-50 text-2xl font-bold text-brand-700">
{countdown}
</div>
<p className="text-sm text-gray-500 mt-2">Returning to registration in {countdown}s</p>
@@ -1210,7 +1210,7 @@ export default function SelfServicePage() {
<button
onClick={resetAndGoToForm}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 transition"
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 transition"
>
OK Next Person
</button>