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
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Calendar } from "lucide-react";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -86,7 +87,7 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
className="px-3 py-1.5 text-sm rounded border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-50">
className="px-3 py-1.5 text-sm rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
{uploading ? "Uploading…" : label}
</button>
</div>
@@ -120,7 +121,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded p-2">
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium">{typeLabel(f.type)}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => mov(idx, -1)} disabled={idx === 0}></button>
@@ -149,7 +150,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
</ul>
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => add("text")}>+ Question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => add("text")}>+ Question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("statement")}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("paragraph")}>+ Heading</button>
</div>
@@ -204,7 +205,7 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
</div>
);
})}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={add}>+ Add option</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={add}>+ Add option</button>
</div>
);
}
@@ -254,7 +255,7 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}></button>
</div>
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addVariant}>+ Add variant</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addVariant}>+ Add variant</button>
</div>
</div>
);
@@ -315,7 +316,7 @@ function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onCha
onChange={updated => { const c = opt.earlyBirdTiers.slice(); c[ti] = updated; updTiers(c); }}
onRemove={() => { const c = opt.earlyBirdTiers.slice(); c.splice(ti, 1); updTiers(c); }} />
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addTier}>+ Add tier</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
</div>
</div>
);
@@ -350,7 +351,7 @@ function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onCha
onChange={updated => { const c = v.earlyBirdTiers.slice(); c[ti] = updated; updVTiers(c); }}
onRemove={() => { const c = v.earlyBirdTiers.slice(); c.splice(ti, 1); updVTiers(c); }} />
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addTier}>+ Add tier</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
</div>
);
})}
@@ -414,7 +415,7 @@ function SectionsDraftEditor({ sections, options, onChange }: {
</div>
</>
)}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={create}>Create section</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
</div>
</div>
);
@@ -479,7 +480,7 @@ function SectionsManager({ eventId }: { eventId: string }) {
</label>
))}
</div>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={create}>Create section</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
</div>
</div>
);
@@ -501,7 +502,7 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
))}
</div>
<div className="flex gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200" onClick={onDelete}>Delete</button>
</div>
</div>
@@ -557,7 +558,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
<ul className="space-y-1">
{items.map(it => (
<li key={it.id} className="flex items-center justify-between text-xs">
<a href={it.url} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline truncate max-w-xs">{it.originalName}</a>
<a href={it.url} target="_blank" rel="noreferrer" className="text-brand-600 hover:underline truncate max-w-xs">{it.originalName}</a>
<button type="button" className="text-red-600 hover:underline ml-2" onClick={() => del(it.id)}>Delete</button>
</li>
))}
@@ -671,7 +672,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
className="border rounded bg-white shadow-lg overflow-y-auto"
>
{visibleResults.map(u => (
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-indigo-50 cursor-pointer" onClick={() => add(u)}>
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-brand-50 cursor-pointer" onClick={() => add(u)}>
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
</li>
))}
@@ -1140,7 +1141,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<button key={i} type="button"
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
className={`px-3 py-1.5 text-xs rounded border whitespace-nowrap transition-colors ${
step === i ? "bg-indigo-600 text-white border-indigo-600"
step === i ? "bg-brand-600 text-white border-brand-600"
: clickable ? "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 cursor-pointer"
: "bg-white text-gray-400 border-gray-100 cursor-default"
}`}
@@ -1156,7 +1157,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<button key={i} type="button"
onClick={() => setPricingSubstep(i as PricingSubstep)}
className={`px-3 py-1 text-xs rounded border whitespace-nowrap transition-colors ${
pricingSubstep === i ? "bg-indigo-100 text-indigo-700 border-indigo-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
pricingSubstep === i ? "bg-brand-100 text-brand-700 border-brand-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
}`}
>{s}</button>
))}
@@ -1344,13 +1345,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{mode === "create" ? (
isLastStep && isOnLastSubstep ? (
<button type="button" disabled={saving} onClick={handleSave}
className="px-5 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 font-medium">
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Creating…" : "Create Event"}
</button>
) : (
<button type="button" disabled={nextDisabled} onClick={handleNext}
title={basicDetailsIncomplete ? "Fill in title, start, end date, and base price to continue" : optionsIncomplete ? "Every option needs a price before continuing" : undefined}
className="px-4 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-indigo-600">Next </button>
className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-brand-600">Next </button>
)
) : (
<>
@@ -1360,7 +1361,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
)}
<button type="button" disabled={saving || isClosed} onClick={handleSave}
title={isClosed ? "This event is closed — reopen it first to make changes" : undefined}
className="px-5 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 font-medium">
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : "Save Changes"}
</button>
</>
@@ -1379,7 +1380,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
return (
<li className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
<li className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{ev.title}</span>
@@ -1393,7 +1394,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
<span className="text-xs text-indigo-600 shrink-0 mt-0.5">Edit </span>
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit </span>
</li>
);
}
@@ -1461,14 +1462,19 @@ export default function ManageEventsPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Events</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Events</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
</div>
</div>
<div className="flex items-center gap-2">
<button type="button" className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
</div>
</div>