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
-37
View File
@@ -1,37 +0,0 @@
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>
);
}
@@ -1,41 +0,0 @@
"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,19 @@
import React from "react";
import type { LucideIcon } from "lucide-react";
import { TONES, type Tone } from "./tones";
/** A labeled icon + description row, used inside help-guide tab content. */
export function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
const t = TONES[tone] || TONES.gray;
return (
<div className="flex items-start gap-3">
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-4 h-4 " + t.icon} />
</div>
<div>
<div className="font-medium text-gray-800">{title}</div>
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
"use client";
import React from "react";
import { usePathname } from "next/navigation";
import { HelpCircle } from "lucide-react";
import { useHelpGuide } from "@/hooks/useHelpGuide";
import { resolveHelpContent } from "@/content/help/registry";
import HelpGuideModal from "./HelpGuideModal";
/**
* The site's single help entry point — a floating round button, present on
* every page (mounted once in the root layout). Content is resolved from
* the current route via content/help/registry.ts, falling back to general
* baseline content (how to register / view tickets) for any page without a
* dedicated guide yet.
*/
// Unattended/operator-driven screens where a floating help button would just
// get in the way (kiosk touchscreen, first-run setup wizard) — this hook
// still runs, it just renders nothing there.
const EXCLUDED_PREFIXES = ["/self-service", "/setup"];
export default function HelpFab() {
const pathname = usePathname() || "/";
const { slug, content } = resolveHelpContent(pathname);
const { open, openGuide, closeGuide } = useHelpGuide(slug);
if (EXCLUDED_PREFIXES.some(prefix => pathname.startsWith(prefix))) {
return null;
}
return (
<>
<button
type="button"
onClick={openGuide}
aria-label="Help"
// bottom-20 on mobile clears BottomNav (fixed, h-16 + safe-area, z-50).
className="fixed z-40 bottom-20 md:bottom-6 right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
>
<HelpCircle className="w-6 h-6" />
</button>
{open && <HelpGuideModal content={content} onClose={closeGuide} />}
</>
);
}
@@ -0,0 +1,107 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { X, Mail, MessageCircleQuestion } from "lucide-react";
import type { HelpContent } from "@/content/help/types";
export default function HelpGuideModal({ content, onClose }: { content: HelpContent; onClose: (dontShowAgain: boolean) => void }) {
const [tab, setTab] = useState(content.tabs[0]?.key);
const [dontShowAgain, setDontShowAgain] = useState(false);
const activeTab = content.tabs.find(t => t.key === tab) || content.tabs[0];
return (
// Above the site header (Navbar is `sticky top-0 z-50`) and above any
// page-level popup (e.g. the report viewer at z-[60]), since the guide
// can be opened from the FAB while another overlay is showing.
<div className="fixed inset-0 z-[70]">
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-200" onClick={() => onClose(dontShowAgain)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
className="w-full max-w-3xl bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
<MessageCircleQuestion className="w-5 h-5 text-brand-600" />
</div>
<div>
<h2 className="text-base font-semibold text-gray-900">{content.title}</h2>
<p className="text-xs text-gray-500">{content.subtitle}</p>
</div>
</div>
<button className="p-1.5 rounded-full hover:bg-gray-100 transition-colors" onClick={() => onClose(dontShowAgain)} aria-label="Close">
<X className="w-5 h-5" />
</button>
</div>
{content.quickLinks && content.quickLinks.length > 0 && (
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50">
{content.quickLinks.map(link => (
<Link
key={link.href}
href={link.href}
onClick={() => onClose(dontShowAgain)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full border border-brand-200 bg-white text-brand-700 hover:bg-brand-100 hover:border-brand-300 transition-colors"
>
{link.icon && <link.icon className="w-3.5 h-3.5" />}
{link.label}
</Link>
))}
</div>
)}
<div className="flex flex-col sm:flex-row">
{content.tabs.length > 1 && (
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50">
{content.tabs.map(t => {
const Icon = t.icon;
const active = activeTab?.key === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg transition-colors " + (active ? "bg-brand-600 text-white font-medium shadow-sm" : "text-gray-600 hover:bg-white hover:shadow-sm")}
>
<Icon className="w-4 h-4 shrink-0" />
{t.label}
</button>
);
})}
</nav>
)}
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
{activeTab?.content}
</div>
</div>
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50">
{content.supportContact && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<Mail className="w-3.5 h-3.5 shrink-0" />
<span>
{content.supportContact.label}:{" "}
<a href={`mailto:${content.supportContact.email}`} className="text-brand-600 hover:underline">
{content.supportContact.email}
</a>
</span>
</div>
)}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} className="rounded accent-brand-600" />
Don&apos;t show this again
</label>
<button className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors shadow-sm" onClick={() => onClose(dontShowAgain)}>
Got it
</button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import React from "react";
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { TONES, type Tone } from "./tones";
/** A single "quick action" tile — icon chip, title, description, whole tile links out. */
export function QuickActionTile({
icon: Icon,
title,
description,
href,
tone = "brand",
}: {
icon: LucideIcon;
title: string;
description: string;
href: string;
tone?: Tone;
}) {
const t = TONES[tone] || TONES.brand;
return (
<Link
href={href}
className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow transition-shadow"
>
<div className={"w-9 h-9 rounded-lg flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-5 h-5 " + t.icon} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900">{title}</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
</Link>
);
}
export function QuickActionGrid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">{children}</div>;
}
@@ -1,3 +0,0 @@
export const SectionHeader = ({ title }: { title: string }) => (
<h2 className="text-2xl font-semibold mb-6 text-center">{title}</h2>
);
@@ -0,0 +1,71 @@
"use client";
import React from "react";
import Link from "next/link";
import { ArrowRight, ArrowUp, ArrowDown, type LucideIcon } from "lucide-react";
import { TONES, type Tone } from "./tones";
/** A "vs last month"-style delta line. `value` is a percentage; null means no baseline to compare against (shown as "New"). */
export type StatDelta = { value: number | null; label?: string };
/**
* Richer stat tile for dashboard KPI rows (icon chip + label + big value +
* optional delta + optional "View X" link) — scaffolding for Phase 2's
* dashboard rebuilds. Not the same component as reports/StatTile.tsx, which
* stays as-is for Reports' own denser tiles.
*/
export function StatCard({
icon: Icon,
label,
value,
tone = "brand",
href,
linkLabel,
delta,
}: {
icon: LucideIcon;
label: string;
value: string | number;
tone?: Tone;
href?: string;
linkLabel?: string;
delta?: StatDelta;
}) {
const t = TONES[tone] || TONES.brand;
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className={"w-10 h-10 rounded-lg flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-5 h-5 " + t.icon} />
</div>
<div className="min-w-0">
<div className="text-xs text-gray-500 truncate">{label}</div>
<div className="text-xl font-semibold text-gray-900 truncate">{value}</div>
</div>
</div>
{delta && (
<div className={"mt-2 flex items-center gap-1 text-xs font-medium " + (delta.value == null ? "text-gray-400" : delta.value >= 0 ? "text-emerald-600" : "text-rose-600")}>
{delta.value == null ? (
<span>New this month</span>
) : (
<>
{delta.value >= 0 ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
<span>{Math.abs(delta.value).toFixed(0)}%</span>
<span className="text-gray-400 font-normal">{delta.label || "vs last month"}</span>
</>
)}
</div>
)}
{href && (
<Link href={href} className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:underline">
{linkLabel || "View"}
<ArrowRight className="w-3 h-3" />
</Link>
)}
</div>
);
}
export function StatCardRow({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
}
@@ -0,0 +1,23 @@
import React from "react";
function initialsFrom(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
return parts
.slice(0, 2)
.map(p => p[0]!.toUpperCase())
.join("");
}
export function UserAvatar({ name, className = "" }: { name: string; className?: string }) {
return (
<div
className={
"shrink-0 rounded-full bg-brand-600 text-white flex items-center justify-center font-semibold " +
(className || "w-10 h-10 text-sm")
}
>
{initialsFrom(name)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Centralized pastel icon-chip tone map used by stat/quick-action/help
* components (StatCard, QuickActionTile, HelpGuideModal, HelpFab, etc.).
* Mirrors the tone maps already used by reports/StatTile.tsx and the
* retired ReportingGuideModal — new components should use this shared copy
* rather than hand-rolling another one.
*/
export const TONES = {
green: { bg: "bg-emerald-50", icon: "text-emerald-600" },
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
brand: { bg: "bg-brand-50", icon: "text-brand-600" },
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
} as const;
export type Tone = keyof typeof TONES;