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 { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
import { Wallet } from "lucide-react";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
@@ -57,12 +58,17 @@ export default function EventCashupPage() {
return (
<div className="max-w-4xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<button className="text-xs text-indigo-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold mt-1">{data?.event?.title || "Event"} Cashup</h1>
<div className="flex items-center justify-between gap-3 flex-wrap">
<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">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<div>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} Cashup</h1>
</div>
</div>
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
<span className={"text-xs px-2 py-1 rounded-full font-medium " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
@@ -70,9 +76,9 @@ export default function EventCashupPage() {
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
<div className="flex gap-2 border-b">
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
</div>
{loading && <div className="text-sm text-gray-400">Loading</div>}
@@ -169,7 +175,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Event costs</div>
{!isClosed && editingId === null && (
<button className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={startNew}>+ Add cost</button>
<button className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={startNew}>+ Add cost</button>
)}
</div>
@@ -199,7 +205,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
{!isClosed && (
<td className="py-1.5 text-right whitespace-nowrap">
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-brand-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
</td>
)}
@@ -264,7 +270,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
</div>
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={cancel} disabled={saving}>Cancel</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
)}
@@ -378,7 +384,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
<td className="py-2.5 text-right pl-2">
{r.userId && (
<button
className="text-xs text-indigo-600 hover:underline whitespace-nowrap"
className="text-xs text-brand-600 hover:underline whitespace-nowrap"
onClick={() => setEditingUserId(editingUserId === r.userId ? null : r.userId)}
>
{r.cash.actual != null ? "Edit count" : "Enter count"}
@@ -495,7 +501,7 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={onCancel} disabled={saving}>Cancel</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
</div>
</div>
);
@@ -803,7 +809,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<div className="flex flex-wrap gap-2 justify-end pt-1">
<button className="text-xs px-3 py-1.5 rounded border" disabled={busy} onClick={saveDraft}>Save draft</button>
<button className="text-xs px-3 py-1.5 rounded bg-amber-600 text-white hover:bg-amber-700" disabled={busy} onClick={quickClose}>Quick close (skip cashup)</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
</div>
</div>
)}
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
export default function CashupLandingPage() {
const { token } = useAuth();
@@ -46,14 +47,19 @@ export default function CashupLandingPage() {
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">Post-event Cashup</h1>
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
<div className="flex items-center justify-between gap-3 flex-wrap">
<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">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900">Post-event Cashup</h1>
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
</div>
</div>
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
onClick={() => router.push("/dashboard")}
>Back</button>
</div>
@@ -87,7 +93,7 @@ export default function CashupLandingPage() {
return (
<li
key={ev.id}
className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
onClick={() => router.push(`/dashboard/admin/cashup/${ev.id}`)}
>
<div className="min-w-0">
@@ -101,7 +107,7 @@ export default function CashupLandingPage() {
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` ${new Date(ev.endDate).toLocaleDateString()}` : ""}
</div>
</div>
<span className="text-xs text-indigo-600 shrink-0">Manage </span>
<span className="text-xs text-brand-600 shrink-0">Manage </span>
</li>
);
})}
+127 -164
View File
@@ -6,6 +6,44 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
const formatCount = (n: number) => (n || 0).toLocaleString();
const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/admin/users", label: "Manage users", description: "Create, edit, change roles and passwords", icon: Users },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/admin/registrations", label: "Manage registrations", description: "Cancel, update status, and search registrations", icon: ClipboardList },
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
revenue: OverviewMetric;
donations: OverviewMetric;
trend: { date: string; revenue: number }[];
topEvents: { eventId: string; title: string; revenue: number; registrations: number; ticketsSold: number }[];
};
export default function AdminDashboardPage() {
const { user, loading, token } = useAuth();
@@ -18,29 +56,26 @@ export default function AdminDashboardPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/admin) that
// computes it all server-side — no more separate calls plus a full payments/events pull
// just to reduce them down to a couple of numbers client-side.
// useStableState skips the re-render entirely when a poll returns identical data, and
// hasLoadedOnce below means "Refreshing…" only ever shows for the very first load —
// together these stop the stats panels from flickering on every 15s poll.
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
// computed server-side — no full payments/events list ever ships to the client.
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
const loadStats = async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/admin", { authToken: token });
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
const [dash, overviewData] = await Promise.all([
apiFetch<any>("/api/stats/admin", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
} catch (e) {
// ignore errors for dashboard summaries
} finally {
@@ -61,18 +96,15 @@ export default function AdminDashboardPage() {
loadStats();
}, 15000, !!token);
const paymentsTabValue = paymentStats
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
: 0;
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Admin Dashboard{user ? ` ${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>Manage users</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>Manage registrations</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>WhatsApp API</button>
</div>
<div className="max-w-6xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
{!isAdmin && (
@@ -81,158 +113,89 @@ export default function AdminDashboardPage() {
</div>
)}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>
Manage users
<div className="text-xs text-white/90">Create, edit, change roles and passwords</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>
Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>
Manage registrations
<div className="text-xs text-white/90">Cancel, update status, and search registrations</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>
Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>
Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>
Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>
Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/forms")}>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees")}>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees")}>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>
Manage WhatsApp API
<div className="text-xs text-white/90">Manage the WhatsApp API config</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/cashup")}>
Post-event Cashup
<div className="text-xs text-white/90">Set costs, reconcile takings, and close out events</div>
</button>
</div>
</div>
<StatCardRow>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
<div className="text-lg font-semibold mb-3">Quick actions</div>
<QuickActionGrid>
{QUICK_ACTIONS.map(a => (
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
))}
</QuickActionGrid>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Revenue trend past month</h2>
{overview && overview.trend.length > 0 ? (
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
) : (
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Payments</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{paymentStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">R{paymentStats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Past Week</div>
<div className="text-lg font-semibold">R{paymentStats.totalWeek}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Past Month</div>
<div className="text-lg font-semibold">R{paymentStats.totalMonth}</div>
</div>
</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
{overview && overview.topEvents.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event</TableHead>
<TableHead className="text-right">Registrations</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Tickets sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.topEvents.map(e => (
<TableRow key={e.eventId}>
<TableCell className="font-medium">{e.title}</TableCell>
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
</TableRow>
))}
</ul>
</div>
</TableBody>
</Table>
) : (
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
)}
</div>
</div>
<div>
<div className="space-y-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Admin stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Payments overview</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<div className="flex gap-1 mb-3 bg-gray-100 rounded-lg p-1">
{(["today", "week", "month"] as const).map(t => (
<button
key={t}
type="button"
onClick={() => setPaymentsTab(t)}
className={"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors " + (paymentsTab === t ? "bg-white text-brand-700 shadow-sm" : "text-gray-500 hover:text-gray-700")}
>
{t === "today" ? "Today" : t === "week" ? "Past week" : "Past month"}
</button>
))}
</div>
{paymentStats ? (
<div className="text-2xl font-semibold text-gray-900">{formatRand(paymentsTabValue)}</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
</div>
<div className="text-sm text-gray-600 mt-6">
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
</div>
<p className="text-sm text-gray-600">As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
</div>
</div>
</div>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -185,9 +186,17 @@ export default function AdminRegistrationsPage() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manage Registrations</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 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">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Manage Registrations</h1>
<p className="text-sm text-gray-500">{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total</p>
</div>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!isAdmin && (
@@ -304,7 +313,7 @@ export default function AdminRegistrationsPage() {
{/* Actions */}
<div className="flex items-center gap-2 py-2 border-b border-gray-200 mb-3">
<select
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-60"
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-60"
value={r.status}
onChange={e => updateStatus(r, e.target.value)}
disabled={r.status === 'cancelled'}
File diff suppressed because it is too large Load Diff
+23 -11
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
interface UserItem {
id: string;
@@ -206,11 +208,19 @@ export default function AdminUsersPage() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">User Management</h1>
<div className="flex items-center justify-between mb-4 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">
<UsersIcon className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500">{total} user{total !== 1 ? "s" : ""} total</p>
</div>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => setCreateOpen(v => !v)}>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={() => setCreateOpen(v => !v)}>
{createOpen ? "Close" : "Create user"}
</button>
</div>
@@ -249,7 +259,7 @@ export default function AdminUsersPage() {
</select>
</div>
<div className="flex items-end">
<button disabled={creating} className="px-3 py-2 rounded bg-indigo-600 text-white disabled:opacity-50" type="submit">
<button disabled={creating} className="px-3 py-2 rounded bg-brand-600 text-white disabled:opacity-50" type="submit">
{creating ? "Creating…" : "Create"}
</button>
</div>
@@ -298,8 +308,7 @@ export default function AdminUsersPage() {
</div>
<div className="text-xs text-gray-500 mb-2">
{total} user{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
{total > 0 && `Page ${page} of ${totalPages}`}
</div>
<div className="overflow-auto">
@@ -325,7 +334,7 @@ export default function AdminUsersPage() {
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
</td>
<td className="p-2">
<span className="capitalize">{u.role}</span>
<RoleBadge role={u.role as RoleBadgeRole} />
</td>
<td className="p-2">
<span>{u.phoneNumber || ""}</span>
@@ -334,7 +343,10 @@ export default function AdminUsersPage() {
<span className="capitalize">{u.notificationPreference || "email"}</span>
</td>
<td className="p-2">
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${u.isActive ? "bg-green-50 text-green-700" : "bg-gray-100 text-gray-500"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${u.isActive ? "bg-green-500" : "bg-gray-400"}`} />
{u.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="p-2">
<div className="flex gap-1 flex-wrap">
@@ -384,7 +396,7 @@ export default function AdminUsersPage() {
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>
@@ -461,7 +473,7 @@ export default function AdminUsersPage() {
</div>
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
@@ -1,831 +1,15 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Types ────────────────────────────────────────────────────────────────────
type WAStatus =
| "WORKING"
| "CONNECTED"
| "SCAN_QR_CODE"
| "STARTING"
| "FAILED"
| "STOPPED"
| string;
interface ConfigResponse {
tokenMasked: string;
instanceId: string;
hasToken: boolean;
hasInstance: boolean;
configured: boolean;
}
interface StatusResponse {
status: WAStatus;
message?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
WORKING: "bg-green-100 text-green-800 border-green-300",
CONNECTED: "bg-green-100 text-green-800 border-green-300",
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
FAILED: "bg-red-100 text-red-800 border-red-300",
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
};
const STATUS_ICONS: Record<string, string> = {
WORKING: "🟢",
CONNECTED: "🟢",
SCAN_QR_CODE: "📷",
STARTING: "🔄",
FAILED: "🔴",
STOPPED: "⚫",
};
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
function Spinner() {
return (
<svg
className="animate-spin h-4 w-4 text-indigo-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
function Alert({
type,
children,
}: {
type: "ok" | "err" | "info";
children: React.ReactNode;
}) {
const cls =
type === "ok"
? "bg-green-50 text-green-800 border-green-200"
: type === "err"
? "bg-red-50 text-red-800 border-red-200"
: "bg-blue-50 text-blue-800 border-blue-200";
return (
<div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function WhatsAppAdminPage() {
const { user, loading, token } = useAuth();
// WhatsApp management moved into Site Settings (its own tab) — this route now
// just redirects there so old links/bookmarks (and the backend's failure-alert
// email, which links here) still land somewhere useful.
export default function WhatsAppRedirectPage() {
const router = useRouter();
const isAdmin = useMemo(() => user?.role === "admin", [user]);
useEffect(() => {
if (loading) return;
if (!user || !isAdmin) router.replace("/dashboard");
}, [user, loading, isAdmin, router]);
// ── Config state (drives wizard steps) ──────────────────────────────────────
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
const [cfgLoading, setCfgLoading] = useState(true);
// Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
// ── Step 1 inputs ────────────────────────────────────────────────────────────
const [inputToken, setInputToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
// ── Step 2 inputs ────────────────────────────────────────────────────────────
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
const [inputInstanceId, setInputInstanceId] = useState("");
const [savingInstance, setSavingInstance] = useState(false);
// ── Step 3: session state ────────────────────────────────────────────────────
const [status, setStatus] = useState<WAStatus | null>(null);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [qrSrc, setQrSrc] = useState<string | null>(null);
const [pairingPhone, setPairingPhone] = useState("");
// ── Shared action feedback ───────────────────────────────────────────────────
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// ── Load config ──────────────────────────────────────────────────────────────
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
setCfg(res);
} catch {
// network error — leave cfg null, user sees loading state
} finally {
setCfgLoading(false);
}
}, [token]);
useEffect(() => { fetchConfig(); }, [fetchConfig]);
// ── Status fetch (step 3 only) ───────────────────────────────────────────────
const fetchStatus = useCallback(async () => {
if (!token || step !== 3) return;
try {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch (e: any) {
// Re-fetch config — if the session was not found, backend clears the
// instance ID and the step recomputes to 2 (Session Instance setup).
await fetchConfig();
setStatus("FAILED");
setStatusMsg(null);
}
}, [token, step, fetchConfig]);
useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]);
// Auto-poll status when not stable
useEffect(() => {
if (step !== 3 || status === null) return;
if (ACTIVE_STATUSES.has(status)) return;
const id = setInterval(fetchStatus, 5_000);
return () => clearInterval(id);
}, [step, status, fetchStatus]);
// ── QR fetch ─────────────────────────────────────────────────────────────────
const fetchQr = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
} catch {
setQrSrc(null);
}
}, [token]);
useEffect(() => {
if (status === "SCAN_QR_CODE") { fetchQr(); }
else { setQrSrc(null); }
}, [status, fetchQr]);
// Auto-refresh QR every 20s while waiting
useEffect(() => {
if (status !== "SCAN_QR_CODE") return;
const id = setInterval(fetchQr, 20_000);
return () => clearInterval(id);
}, [status, fetchQr]);
// ── Generic session action ───────────────────────────────────────────────────
const doAction = async (action: string, body?: object) => {
if (!token) return;
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<any>(`/api/whatsapp/${action}`, {
method: "POST",
authToken: token,
body,
});
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
} catch (e: any) {
let msg = e?.message || `${action} failed.`;
try { msg = JSON.parse(msg)?.message || msg; } catch {}
// SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so
// the wizard steps back to Step 2; no need to show an error message.
await fetchConfig();
if (!msg.includes("SESSION_NOT_FOUND")) {
setActionMsg({ type: "err", text: msg });
}
await fetchStatus();
} finally {
setBusy(null);
}
};
// ─── Step 1: Save token ──────────────────────────────────────────────────────
const saveToken = async () => {
if (!inputToken.trim()) {
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
return;
}
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: inputToken.trim(), instanceId: "" },
});
setInputToken("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
} finally {
setSavingToken(false);
}
};
// ─── Step 2: Enter existing instance ID ──────────────────────────────────────
const saveInstanceId = async () => {
if (!inputInstanceId.trim()) {
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
return;
}
setSavingInstance(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: "", instanceId: inputInstanceId.trim() },
// token left blank → backend keeps existing token
});
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
} finally {
setSavingInstance(false);
}
};
// ─── Step 2: Create new instance ─────────────────────────────────────────────
const createInstance = async () => {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<any>("/api/whatsapp/create-instance", {
method: "POST",
authToken: token!,
});
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
} finally {
setSavingInstance(false);
}
};
// ─── Pairing code ────────────────────────────────────────────────────────────
const requestPairingCode = async () => {
if (!pairingPhone.trim()) {
setActionMsg({ type: "err", text: "Enter your phone number first." });
return;
}
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
};
// ─── Reset credentials (go back to step 1) ───────────────────────────────────
const resetToken = async () => {
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: "_clear_", instanceId: "" },
});
} catch {}
// Force a re-read — even if the above fails, clear local state
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
// ────────────────────────────────────────────────────────────────────────────
// Render
// ────────────────────────────────────────────────────────────────────────────
if (loading || cfgLoading) {
return (
<div className="max-w-xl mx-auto w-full p-6 flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Loading
</div>
);
}
return (
<div className="max-w-xl mx-auto w-full p-6 space-y-6">
{/* Back button */}
<button
onClick={() => router.push("/dashboard")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
Back
</button>
{/* Header */}
<div className="flex items-center gap-3">
<span className="text-3xl">💬</span>
<div>
<h1 className="text-2xl font-semibold leading-tight">WhatsApp Integration</h1>
<p className="text-sm text-gray-500">Powered by WAWP</p>
</div>
</div>
{/* Step indicator */}
<StepIndicator step={step} />
{/* Global action message */}
{actionMsg && (
<Alert type={actionMsg.type}>{actionMsg.text}</Alert>
)}
{/* ── STEP 1: Enter access token ──────────────────────────────────────── */}
{step === 1 && (
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Step 1 Enter your WAWP Access Token</h2>
<p className="text-sm text-gray-600">
Your access token is found in your WAWP account dashboard at{" "}
<a
href="https://app.wawp.net"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
app.wawp.net
</a>
.
</p>
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700">Access Token</label>
<input
type="password"
value={inputToken}
onChange={e => setInputToken(e.target.value)}
onKeyDown={e => e.key === "Enter" && saveToken()}
placeholder="Paste your WAWP access token"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={saveToken}
disabled={savingToken}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Token & Continue"}
</button>
</section>
)}
{/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */}
{step === 2 && (
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Step 2 Set Up Session Instance</h2>
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">
Token: {cfg?.tokenMasked}
</span>
</div>
<p className="text-sm text-gray-600">
You need a WAWP session instance. Either create a brand-new one, or enter an
existing Instance ID.
</p>
{/* Tab toggle */}
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
<button
onClick={() => setInstanceMode("create")}
className={`flex-1 px-4 py-2.5 transition-colors ${
instanceMode === "create"
? "bg-indigo-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
Create new instance
</button>
<button
onClick={() => setInstanceMode("enter")}
className={`flex-1 px-4 py-2.5 border-l transition-colors ${
instanceMode === "enter"
? "bg-indigo-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
Enter existing ID
</button>
</div>
{instanceMode === "create" && (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Click below to create a new WAWP session. The Instance ID will be saved
automatically.
</p>
<button
onClick={createInstance}
disabled={savingInstance}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingInstance && <Spinner />}
{savingInstance ? "Creating…" : "Create Instance"}
</button>
</div>
)}
{instanceMode === "enter" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Instance ID
</label>
<input
type="text"
value={inputInstanceId}
onChange={e => setInputInstanceId(e.target.value)}
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
placeholder="e.g. BF14B761C364"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={saveInstanceId}
disabled={savingInstance}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingInstance && <Spinner />}
{savingInstance ? "Saving…" : "Save & Continue"}
</button>
</div>
)}
<button
onClick={resetToken}
className="text-xs text-gray-400 hover:text-red-500 hover:underline"
>
Change access token
</button>
</section>
)}
{/* ── STEP 3: Full management ─────────────────────────────────────────── */}
{step === 3 && (
<>
{/* Status card */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Session Status</h2>
<button
onClick={fetchStatus}
className="text-xs text-indigo-600 hover:underline"
>
Refresh
</button>
</div>
{status === null ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Fetching status
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
<span
className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${
STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"
}`}
>
{status}
</span>
</div>
)}
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
{status === "FAILED" && (
<Alert type="err">
The session has failed. The system will attempt to auto-restart. You can also
restart manually below.
</Alert>
)}
{status && POLLING_STATUSES.has(status) && (
<p className="text-xs text-gray-400 flex items-center gap-1">
<Spinner /> Auto-refreshing every 5 seconds
</p>
)}
{/* Config info strip */}
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
<span>
Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span>
</span>
<span>
Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span>
</span>
</div>
</section>
{/* QR Code */}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Scan QR Code</h2>
<button
onClick={fetchQr}
className="text-xs text-indigo-600 hover:underline"
>
Refresh QR
</button>
</div>
<p className="text-sm text-gray-600">
Open WhatsApp Linked Devices Link a Device, then scan the code below.
</p>
{qrSrc ? (
<img
src={qrSrc}
alt="WhatsApp QR Code"
className="w-56 h-56 border rounded-lg"
/>
) : (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Spinner /> Loading QR
</div>
)}
<p className="text-xs text-gray-400">
QR codes expire after ~20 seconds click Refresh QR if it stops working.
</p>
</section>
)}
{/* Pairing code */}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Link by Phone Number Instead</h2>
<p className="text-sm text-gray-600">
Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing
code on your phone.
</p>
<div className="flex gap-2">
<input
type="tel"
value={pairingPhone}
onChange={e => setPairingPhone(e.target.value)}
placeholder="082 123 4567"
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
onClick={requestPairingCode}
disabled={busy === "request-code"}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{busy === "request-code" && <Spinner />}
{busy === "request-code" ? "Sending…" : "Send code"}
</button>
</div>
</section>
)}
{/* Session controls */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Session Controls</h2>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Start"
busyLabel="Starting…"
isBusy={busy === "start"}
disabled={!!busy}
color="green"
onClick={() => doAction("start")}
/>
<ActionButton
label="Restart"
busyLabel="Restarting…"
isBusy={busy === "restart"}
disabled={!!busy}
color="amber"
onClick={() => doAction("restart")}
/>
<ActionButton
label="Logout"
busyLabel="Logging out…"
isBusy={busy === "logout"}
disabled={!!busy}
color="red-outline"
onClick={() => {
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
doAction("logout");
}}
/>
</div>
</section>
{/* Instance management */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Instance Management</h2>
<p className="text-sm text-gray-600">
Create a brand-new instance or permanently delete the current one. Deleting will
require you to set up a new instance.
</p>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Create New Instance"
busyLabel="Creating…"
isBusy={busy === "create-instance"}
disabled={!!busy}
color="blue"
onClick={() => doAction("create-instance")}
/>
<ActionButton
label="Delete Instance"
busyLabel="Deleting…"
isBusy={busy === "delete-instance"}
disabled={!!busy}
color="red-outline"
onClick={() => {
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
doAction("delete-instance");
}}
/>
</div>
</section>
{/* Update credentials */}
<details className="border rounded-xl bg-white shadow-sm">
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
<span>Update Credentials</span>
<span className="text-gray-400 text-xs">expand </span>
</summary>
<div className="px-5 pb-5 space-y-3 border-t pt-4">
<p className="text-sm text-gray-600">
Change your WAWP access token or Instance ID. Leave a field blank to keep the
current value.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
New Access Token
</label>
<input
type="password"
value={inputToken}
onChange={e => setInputToken(e.target.value)}
placeholder="Leave blank to keep current token"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
New Instance ID
</label>
<input
type="text"
value={inputInstanceId}
onChange={e => setInputInstanceId(e.target.value)}
placeholder="Leave blank to keep current instance"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={async () => {
if (!inputToken.trim() && !inputInstanceId.trim()) {
setActionMsg({ type: "err", text: "Enter at least one field to update." });
return;
}
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: {
token: inputToken.trim() || undefined,
instanceId: inputInstanceId.trim() || undefined,
},
});
setActionMsg({ type: "ok", text: "Credentials updated." });
setInputToken("");
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
} finally {
setSavingToken(false);
}
}}
disabled={savingToken}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Changes"}
</button>
</div>
</details>
</>
)}
<p className="text-xs text-gray-400 text-center">
WhatsApp notifications powered by{" "}
<a
href="https://wawp.net"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
WAWP
</a>
. Session auto-recovers on failure; admin alert sent if recovery fails.
</p>
</div>
);
router.replace("/dashboard/admin/settings?tab=whatsapp");
}, [router]);
return <div className="p-6 text-sm text-gray-500">Redirecting</div>;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StepIndicator({ step }: { step: number }) {
const steps = [
{ n: 1, label: "Access Token" },
{ n: 2, label: "Session Instance" },
{ n: 3, label: "Connected" },
];
return (
<div className="flex items-center gap-0">
{steps.map((s, i) => {
const done = step > s.n;
const current = step === s.n;
return (
<React.Fragment key={s.n}>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
done
? "bg-green-500 border-green-500 text-white"
: current
? "bg-indigo-600 border-indigo-600 text-white"
: "bg-white border-gray-300 text-gray-400"
}`}
>
{done ? "✓" : s.n}
</div>
<span
className={`text-xs mt-1 font-medium ${
done || current ? "text-gray-700" : "text-gray-400"
}`}
>
{s.label}
</span>
</div>
{i < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${
done ? "bg-green-400" : "bg-gray-200"
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
}
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
function ActionButton({
label,
busyLabel,
isBusy,
disabled,
color,
onClick,
}: {
label: string;
busyLabel: string;
isBusy: boolean;
disabled: boolean;
color: ButtonColor;
onClick: () => void;
}) {
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
const colors: Record<ButtonColor, string> = {
green: "bg-green-600 text-white hover:bg-green-700",
amber: "bg-amber-500 text-white hover:bg-amber-600",
blue: "bg-blue-600 text-white hover:bg-blue-700",
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
};
return (
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
{isBusy && <Spinner />}
{isBusy ? busyLabel : label}
</button>
);
}
+34 -21
View File
@@ -7,6 +7,19 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
// mockups). Every other /dashboard/* route relies on the global floating
// help button instead of sidebar nav, same as before this redesign.
const SIDEBAR_ROUTES = [
"/dashboard/admin",
"/dashboard/supervisor",
"/dashboard/staff",
"/dashboard/user",
"/dashboard/user/profile",
"/dashboard/admin/settings",
];
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
@@ -63,31 +76,31 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
// Sub-pages within staff/supervisor/admin (e.g. /dashboard/supervisor/reports,
// /dashboard/admin/cashup/[id]) are full-width workspaces with their own internal navigation
// and header — the dashboard sidebar's section links (My Events, Profile, Admin, etc.) would
// just crowd them. The sidebar stays visible only on each role's root landing page; every
// deeper sub-page hides it. Navigating back is never a dead end: Navbar's "Dashboard" link is
// always present and /dashboard auto-redirects to the role root.
const SIDEBAR_ROOTS = ["/dashboard/staff", "/dashboard/supervisor", "/dashboard/admin"];
const hideSidebar = !!pathname && SIDEBAR_ROOTS.some(root => pathname !== root && pathname.startsWith(root + "/"));
const showSidebar = !!pathname && SIDEBAR_ROUTES.includes(pathname);
if (showSidebar) {
// App-shell layout: the whole viewport is claimed (h-screen, no body
// scroll) so the sidebar can be a plain sibling column that never
// scrolls — only the content column (main + footer) scrolls internally.
return (
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col md:flex-row">
{!hideSidebar && (
<>
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
</>
)}
<main className="flex-1 p-6 bg-gray-50">{children}</main>
</div>
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
);
@@ -7,6 +7,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
import { Ticket } from "lucide-react";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -229,10 +230,15 @@ function EventTicketsContent() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event Tickets</h1>
<div className="flex items-center justify-between mb-4 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">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Event Tickets</h1>
</div>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
onClick={() => router.push("/dashboard")}
>
Back
@@ -325,7 +331,7 @@ function EventTicketsContent() {
onClick={() =>
printTickets(filteredTickets.filter((t) => !t.isUsed))
}
className="px-3 py-2 bg-blue-600 text-white rounded"
className="px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded"
>
Print unused
</button>
+40 -51
View File
@@ -6,6 +6,14 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
] as const;
export default function StaffDashboardPage() {
const { user, loading, token } = useAuth();
@@ -60,13 +68,10 @@ export default function StaffDashboardPage() {
}, 10000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Staff Dashboard{user ? ` ${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets</button>
</div>
<div className="max-w-6xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
</div>
{!canView && (
@@ -75,20 +80,23 @@ export default function StaffDashboardPage() {
</div>
)}
<StatCardRow>
<StatCard icon={Activity} label="Scans today" value={stats ? stats.totalToday : "—"} tone="brand" />
<StatCard icon={UserCheck} label="My scans today" value={stats ? stats.myToday : "—"} tone="blue" />
<StatCard icon={Clock} label="Last hour" value={stats ? stats.lastHour : "—"} tone="amber" />
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
<div className="text-lg font-semibold mb-3">Quick actions</div>
<QuickActionGrid>
{QUICK_ACTIONS.map(a => (
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
))}
</QuickActionGrid>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-2 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
</div>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
@@ -101,7 +109,7 @@ export default function StaffDashboardPage() {
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0, 8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
@@ -112,37 +120,18 @@ export default function StaffDashboardPage() {
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Scanner stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats</div>}
{stats && (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{stats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{stats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{stats.lastHour}</div>
</div>
</div>
)}
{stats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
{stats?.byStaff?.length > 0 ? (
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
) : (
<div className="text-sm text-gray-500">No scans recorded yet today.</div>
)}
</div>
</div>
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { QrCode } from "lucide-react";
export default function TicketScanningPage() {
const router = useRouter();
@@ -246,9 +247,14 @@ export default function TicketScanningPage() {
<div className="max-w-6xl mx-auto w-full p-4">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Ticket Scanning</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 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">
<QrCode className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">
Use the button to start/stop scanning. The back camera will be used when available.
@@ -363,7 +369,7 @@ export default function TicketScanningPage() {
{confirmModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-1 text-indigo-700">Confirm Scan</h3>
<h3 className="text-lg font-semibold mb-1 text-brand-700">Confirm Scan</h3>
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
@@ -382,7 +388,7 @@ export default function TicketScanningPage() {
inputMode="numeric"
min={1}
max={confirmModal.remaining}
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
value={confirmQtyRaw}
onChange={e => setConfirmQtyRaw(e.target.value)}
onBlur={() => {
@@ -404,7 +410,7 @@ export default function TicketScanningPage() {
</button>
<button
onClick={commitScan}
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
>
Confirm Scan
</button>
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { DoorOpen } from "lucide-react";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
@@ -343,7 +344,12 @@ export default function AtTheDoorPage() {
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h1 className="text-2xl font-semibold shrink-0">At The Door</h1>
<div className="flex items-center gap-3 shrink-0">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<DoorOpen className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
</div>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
value={eventId}
@@ -392,7 +398,7 @@ export default function AtTheDoorPage() {
mode === m
? m === "refund"
? "bg-red-600 text-white border-red-600"
: "bg-indigo-600 text-white border-indigo-600"
: "bg-brand-600 text-white border-brand-600"
: "bg-white hover:bg-gray-50"
}`}
>
@@ -538,7 +544,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
</div>
</div>
<div className="flex gap-2">
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-indigo-600 text-white">Open</button>
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white">Open</button>
<button onClick={() => onEditRegistration(r)} className="px-2 py-1 text-xs rounded bg-emerald-600 text-white">Edit</button>
<button onClick={() => onDonation(r.user)} className="px-2 py-1 text-xs rounded bg-amber-500 text-white">Donation</button>
</div>
@@ -712,7 +718,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
{balance > 0 && (
<button
onClick={() => setAmount(String(balance))}
className="px-4 rounded bg-indigo-600 text-white text-sm font-medium"
className="px-4 rounded bg-brand-600 text-white text-sm font-medium"
>
Full
</button>
@@ -747,7 +753,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
<div className="mt-4 flex gap-2">
<button
onClick={() => onSuccess(registration)}
className="flex-1 py-3 rounded bg-indigo-600 text-white text-sm font-medium"
className="flex-1 py-3 rounded bg-brand-600 text-white text-sm font-medium"
>
Print Tickets
</button>
@@ -1113,7 +1119,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
className="ml-auto px-4 py-2 text-sm rounded bg-brand-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
@@ -1175,7 +1181,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div key={opt.id} className="border rounded-xl overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b font-medium text-sm">
{opt.name}
{opt.isMainTicket && <span className="ml-1.5 text-xs text-blue-600 font-normal"> Main</span>}
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal"> Main</span>}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
@@ -1424,7 +1430,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
className={`flex-1 py-2 rounded border text-sm font-medium ${
channel === c
? c === "whatsapp" ? "bg-green-600 text-white border-green-600"
: c === "both" ? "bg-indigo-600 text-white border-indigo-600"
: c === "both" ? "bg-brand-600 text-white border-brand-600"
: "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
@@ -1472,7 +1478,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
type="button"
onClick={send}
disabled={sending}
className="flex-1 py-2 rounded bg-indigo-600 text-white text-sm font-medium disabled:opacity-60"
className="flex-1 py-2 rounded bg-brand-600 text-white text-sm font-medium disabled:opacity-60"
>
{sending ? "Sending…" : "Send Tickets"}
</button>
@@ -1872,7 +1878,7 @@ function NewAttendeeModal({ open, onClose, seed, onConfirm }: {
className={`flex-1 py-2 transition-colors ${
notifPref === 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"
}`}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail } from "lucide-react";
type Attendee = { id: string; name: string; email: string; pref: string };
@@ -84,7 +85,7 @@ function AttendeesCheckboxDropdown({
<div className="flex gap-1 flex-wrap">
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50"
className="text-xs px-2 py-0.5 rounded border border-brand-300 text-brand-700 hover:bg-brand-50"
onClick={() => onChange(attendees.filter(a => prefMatch(a.pref)).map(a => a.id))}
>
Select {channel === "email" ? "Email/both" : "WhatsApp/both"}
@@ -114,7 +115,7 @@ function AttendeesCheckboxDropdown({
<span className="truncate flex-1">
{a.name ? `${a.name} <${a.email}>` : a.email}
</span>
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-indigo-700 bg-indigo-50" : "text-amber-700 bg-amber-50"}`}>
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-brand-700 bg-brand-50" : "text-amber-700 bg-amber-50"}`}>
{prefLabel(a.pref)}
</span>
</label>
@@ -479,10 +480,15 @@ function EmailAttendeesPageInner() {
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Email Attendees</h1>
<div className="flex items-center justify-between mb-4 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">
<Mail className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Email Attendees</h1>
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
</div>
@@ -497,19 +503,19 @@ function EmailAttendeesPageInner() {
{/* Tabs like on Payments page */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="attendees" className="hidden" checked={tab==='attendees'} onChange={() => setTab('attendees')} />
Attendees
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="automations" className="hidden" checked={tab==='automations'} onChange={() => setTab('automations')} />
Automations
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="broadcasts" className="hidden" checked={tab==='broadcasts'} onChange={() => setTab('broadcasts')} />
Broadcasts
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="scheduled" className="hidden" checked={tab==='scheduled'} onChange={() => setTab('scheduled')} />
Scheduled
</label>
@@ -636,7 +642,7 @@ function EmailAttendeesPageInner() {
<div className="flex items-center flex-wrap gap-2">
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={onPreview}>Preview recipients</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={onSend}>{sending ? 'Sending…' : 'Send now'}</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={onSend}>{sending ? 'Sending…' : 'Send now'}</button>
<button type="button" disabled={sending || !scheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
@@ -776,7 +782,7 @@ Jane Doe <jane@example.com>
}
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={async () => {
<button type="button" className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError('Not authenticated'); return; }
@@ -915,7 +921,7 @@ Jane Doe <jane@example.com>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={editWhen} onChange={e => setEditWhen(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={saveEdit}>{savingEdit ? 'Saving…' : 'Save changes'}</button>
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={saveEdit}>{savingEdit ? 'Saving…' : 'Save changes'}</button>
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -85,8 +86,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
</ul>
)}
<div className="flex items-center gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
</div>
</div>
)}
@@ -202,9 +203,14 @@ function EventOptionsContent() {
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event options</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
<div className="flex items-center justify-between mb-4 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">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Event options</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
</div>
{!canView && (
@@ -280,7 +286,7 @@ function EventOptionsContent() {
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm">Create option</button>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm">Create option</button>
</div>
</div>
</div>
@@ -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>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
@@ -55,7 +56,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium shrink-0">{typeLabel(f.type)}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium shrink-0">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400 shrink-0">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, -1)} disabled={idx === 0} title="Move up"></button>
@@ -109,7 +110,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={() => addField('text')}>+ Add question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => addField('text')}>+ Add question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('statement')}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('paragraph')}>+ Heading</button>
</div>
@@ -438,12 +439,17 @@ export default function FormsBrowserPage() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 no-print">
<h1 className="text-2xl font-semibold">Forms</h1>
<div className="flex items-center justify-between mb-4 no-print 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">
<FileText className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Forms</h1>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
{mode === 'view' && (
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={onPrint} disabled={items.length === 0}>
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={onPrint} disabled={items.length === 0}>
Print forms
</button>
)}
@@ -459,7 +465,7 @@ export default function FormsBrowserPage() {
{/* Mode tabs */}
<div className="mb-4 flex items-center gap-2 no-print">
{(['view', 'fill', 'manage'] as const).map(m => (
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
<input type="radio" name="mode" value={m} className="hidden" checked={mode===m} onChange={() => setMode(m)} />
{m === 'view' ? 'View responses' : m === 'fill' ? 'Fill / edit responses' : 'Edit form structure'}
</label>
@@ -511,7 +517,7 @@ export default function FormsBrowserPage() {
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
/>
{userId && (
<div className="text-xs text-indigo-600 mt-0.5 truncate max-w-xs">
<div className="text-xs text-brand-600 mt-0.5 truncate max-w-xs">
{allUsers.find(u => u.id === userId)?.name || userId}
<button className="ml-1 text-gray-400 hover:text-gray-600" onMouseDown={e => { e.preventDefault(); setUserId(""); setUserSearch(""); }}></button>
</div>
@@ -521,7 +527,7 @@ export default function FormsBrowserPage() {
{filteredUsers.map(u => (
<button
key={u.id}
className="w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex flex-col"
className="w-full text-left px-3 py-2 text-sm hover:bg-brand-50 flex flex-col"
onMouseDown={e => { e.preventDefault(); setUserId(u.id); setUserSearch(u.name || u.email || u.id); setUserDropdownOpen(false); }}
>
<span className="font-medium truncate">{u.name || "(no name)"}</span>
@@ -538,7 +544,7 @@ export default function FormsBrowserPage() {
</div>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700"
onClick={() => search(false)}
disabled={loadingList}
>
@@ -634,7 +640,7 @@ export default function FormsBrowserPage() {
<div className="mt-3 flex gap-2">
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
onClick={saveForm}
disabled={formLoading}
>
@@ -719,7 +725,7 @@ export default function FormsBrowserPage() {
))}
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
onClick={saveResponses}
disabled={savingFill || mainTicketCount === 0}
>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -61,7 +62,12 @@ export default function ManualRegistrationPage() {
return (
<div className="max-w-xl">
<h1 className="text-xl font-semibold mb-4">Manual Registration</h1>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<UserPlus className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900">Manual Registration</h1>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Event ID</label>
@@ -96,7 +102,7 @@ export default function ManualRegistrationPage() {
</div>
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="bg-blue-600 text-white rounded px-4 py-2 disabled:opacity-60">
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
{busy ? "Submitting..." : "Create"}
</button>
</form>
@@ -198,7 +204,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
</div>
);
}
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -337,9 +338,14 @@ export default function ManualRegistrationPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manual registration</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 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">
<UserPlus className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Manual registration</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
@@ -349,11 +355,11 @@ export default function ManualRegistrationPage() {
)}
<div className="mb-4 flex items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
Register
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
Record Payment
</label>
@@ -395,7 +401,7 @@ export default function ManualRegistrationPage() {
Search existing user <span className="font-normal">(name, email or phone)</span>
</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Start typing to find a user…"
value={userQuery}
autoComplete="off"
@@ -414,7 +420,7 @@ export default function ManualRegistrationPage() {
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
@@ -443,13 +449,13 @@ export default function ManualRegistrationPage() {
<div className="grid gap-2">
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Full name"
value={guest.name}
onChange={e => setGuest({ ...guest, name: e.target.value })}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
type="email"
value={guest.email}
@@ -457,7 +463,7 @@ export default function ManualRegistrationPage() {
required={!registerAsGuest}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Phone (optional)"
value={guest.phoneNumber}
onChange={e => {
@@ -491,7 +497,7 @@ export default function ManualRegistrationPage() {
className={`flex-1 py-2 transition-colors ${
notifPref === p
? p === "whatsapp" ? "bg-green-600 text-white border-green-600"
: 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"
}`}
@@ -529,7 +535,7 @@ export default function ManualRegistrationPage() {
return (
<div key={opt.id} className="border rounded overflow-hidden col-span-full sm:col-span-1">
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-blue-600 font-normal"> Main</span> : null}
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-brand-600 font-normal"> Main</span> : null}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
@@ -586,7 +592,7 @@ export default function ManualRegistrationPage() {
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
)}
@@ -615,7 +621,7 @@ export default function ManualRegistrationPage() {
<div ref={paySearchRef} className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Search by name, email or phone…"
value={payUserQuery}
autoComplete="off"
@@ -633,7 +639,7 @@ export default function ManualRegistrationPage() {
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectPayUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
@@ -669,7 +675,7 @@ export default function ManualRegistrationPage() {
</div>
</div>
<div className="mt-3">
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
</div>
</div>
</div>
+120 -87
View File
@@ -6,6 +6,42 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
const formatCount = (n: number) => (n || 0).toLocaleString();
const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/supervisor/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
revenue: OverviewMetric;
donations: OverviewMetric;
trend: { date: string; revenue: number }[];
topEvents: { eventId: string; title: string; revenue: number; registrations: number; ticketsSold: number }[];
};
export default function SupervisorDashboardPage() {
const { user, loading, token } = useAuth();
@@ -21,16 +57,13 @@ export default function SupervisorDashboardPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/supervisor)
// that computes it all server-side — no more separate calls plus a full payments/events
// pull just to reduce them down to a couple of numbers client-side.
// useStableState skips re-renders when a poll returns identical data, and hasLoadedOnce
// below means "Refreshing…" only shows on the very first load — together these stop the
// stats panels from flickering on every 15s poll.
// /api/stats/supervisor covers scan activity + today's payments (unchanged from before);
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
// with the admin dashboard since supervisors already have full revenue/donation visibility.
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
@@ -39,11 +72,14 @@ export default function SupervisorDashboardPage() {
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/supervisor", { authToken: token });
const [data, overviewData] = await Promise.all([
apiFetch<any>("/api/stats/supervisor", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
setOverview(overviewData);
} catch (e) {
// ignore
} finally {
@@ -65,14 +101,10 @@ export default function SupervisorDashboardPage() {
}, 15000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Supervisor Dashboard{user ? ` ${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
</div>
<div className="max-w-6xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
{!canView && (
@@ -81,52 +113,63 @@ export default function SupervisorDashboardPage() {
</div>
)}
<StatCardRow>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
<div className="text-lg font-semibold mb-3">Quick actions</div>
<QuickActionGrid>
{QUICK_ACTIONS.map(a => (
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
))}
</QuickActionGrid>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/forms") }>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees") }>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees") }>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
</div>
<div className="lg:col-span-2 space-y-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Revenue trend past month</h2>
{overview && overview.trend.length > 0 ? (
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
) : (
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
{overview && overview.topEvents.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event</TableHead>
<TableHead className="text-right">Registrations</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Tickets sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.topEvents.map(e => (
<TableRow key={e.eventId}>
<TableCell className="font-medium">{e.title}</TableCell>
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
@@ -138,21 +181,23 @@ export default function SupervisorDashboardPage() {
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0, 8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
</div>
<div className="space-y-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Scanner activity</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{scanStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="grid grid-cols-3 gap-2">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{scanStats.totalToday}</div>
@@ -169,9 +214,21 @@ export default function SupervisorDashboardPage() {
) : (
<div className="text-sm text-gray-500">No scanner data yet.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Payments today</h2>
{paymentStats ? (
<div className="border rounded-lg p-3 flex items-center justify-between">
<div className="text-sm text-gray-500">Today</div>
<div className="text-base font-semibold">{formatRand(paymentStats.totalToday)}</div>
</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="mt-4">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
@@ -185,30 +242,6 @@ export default function SupervisorDashboardPage() {
)}
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Supervisor stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet } from "lucide-react";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -85,7 +86,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
return (
<div ref={ref} className="relative">
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 disabled:bg-gray-50 pr-7"
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 disabled:bg-gray-50 pr-7"
placeholder={placeholder}
value={query}
disabled={disabled}
@@ -102,7 +103,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">{matches.length} result{matches.length !== 1 ? "s" : ""}</div>
{matches.map(u => (
<button key={u.id} type="button" className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors" onClick={() => { onChange(String(u.id)); setQuery(u.name || ""); setOpen(false); }}>
<button key={u.id} type="button" className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors" onClick={() => { onChange(String(u.id)); setQuery(u.name || ""); setOpen(false); }}>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
@@ -545,9 +546,14 @@ function PaymentsContent() {
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6 overflow-x-hidden">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Payments</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 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">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Payments</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
@@ -560,23 +566,23 @@ function PaymentsContent() {
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="mb-4 flex flex-wrap items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
Payment
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="refund" className="hidden" checked={mode==='refund'} onChange={() => setMode('refund')} />
Refund
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="donation" className="hidden" checked={mode==='donation'} onChange={() => setMode('donation')} />
Donations
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="reconcile" className="hidden" checked={mode==='reconcile'} onChange={() => setMode('reconcile')} />
Reconcile
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
Payment Link
</label>
@@ -617,7 +623,7 @@ function PaymentsContent() {
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
<td className="py-2 pr-3">{tx.methodType || '-'}</td>
<td className="py-2 flex flex-wrap gap-2">
<button className="px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => openReconcileToRegistration(tx)}>To Registration</button>
<button className="px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => openReconcileToRegistration(tx)}>To Registration</button>
<button className="px-2 py-1 rounded bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => openReconcileAsDonation(tx)}>As Donation</button>
<button className="px-2 py-1 rounded bg-gray-600 text-white hover:bg-gray-700" onClick={async () => {
if (!token) return;
@@ -657,7 +663,7 @@ function PaymentsContent() {
</select>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 rounded bg-indigo-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.registrationId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
<button className="px-3 py-1.5 rounded bg-brand-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.registrationId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
<button className="px-3 py-1.5 rounded bg-gray-200 text-gray-800 text-sm" onClick={cancelReconcile}>Cancel</button>
</div>
</div>
@@ -752,7 +758,7 @@ function PaymentsContent() {
</div>
</div>
<div className="mt-3">
<button disabled={submitting} onClick={createPayment} className="mt-2 w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create payment'}</button>
<button disabled={submitting} onClick={createPayment} className="mt-2 w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create payment'}</button>
</div>
</div>
)}
@@ -806,7 +812,7 @@ function PaymentsContent() {
</div>
</div>
<div className="mt-3">
<button disabled={linkGenerating || !linkRegistrationId} onClick={generatePaymentLink} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">
<button disabled={linkGenerating || !linkRegistrationId} onClick={generatePaymentLink} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">
{linkGenerating ? 'Generating…' : 'Generate link'}
</button>
</div>
@@ -816,7 +822,7 @@ function PaymentsContent() {
<div className="text-sm break-all font-mono bg-white border rounded px-2 py-1.5">{linkResult.redirectUrl}</div>
<div className="flex flex-wrap gap-2 pt-1">
<button onClick={copyPaymentLink} className="px-3 py-1.5 text-xs rounded bg-gray-200 hover:bg-gray-300 text-gray-800">{linkCopied ? 'Copied!' : 'Copy link'}</button>
<button disabled={linkSending==='email'} onClick={() => sendPaymentLink('email')} className="px-3 py-1.5 text-xs rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50">{linkSending==='email' ? 'Sending…' : 'Send via Email'}</button>
<button disabled={linkSending==='email'} onClick={() => sendPaymentLink('email')} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{linkSending==='email' ? 'Sending…' : 'Send via Email'}</button>
<button disabled={linkSending==='whatsapp'} onClick={() => sendPaymentLink('whatsapp')} className="px-3 py-1.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50">{linkSending==='whatsapp' ? 'Sending…' : 'Send via WhatsApp'}</button>
</div>
<div className="text-[10px] text-amber-700">Generating a new link for this registration will replace this one the old link will no longer be honored.</div>
@@ -1203,7 +1209,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
<button
disabled={!paymentId || assigning || !(amountNum > 0)}
onClick={assign}
className="mt-2 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm"
className="mt-2 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm"
>
{assigning ? 'Assigning…' : 'Assign donation'}
</button>
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { Suspense } from "react";
import { useRouter } from "next/navigation";
import ReportsV2 from "@/components/reports/ReportsV2";
@@ -8,7 +8,9 @@ export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="w-full p-6">
<ReportsV2 onBack={() => router.push('/dashboard')} />
<Suspense fallback={<div className="text-sm text-gray-400">Loading</div>}>
<ReportsV2 onBack={() => router.push('/dashboard')} />
</Suspense>
</div>
);
}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle } from "lucide-react";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -590,9 +591,14 @@ function WhatsAppAttendeesPageInner() {
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">WhatsApp Attendees</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
<div className="flex items-center justify-between mb-4 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">
<MessageCircle className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">WhatsApp Attendees</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
Back
</button>
</div>
+35 -23
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
export default function DonatePage() {
const { token } = useAuth();
@@ -57,32 +58,43 @@ export default function DonatePage() {
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center shrink-0">
<HandHeart className="w-5 h-5 text-rose-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Make a donation</h1>
<p className="text-sm text-gray-500">Support an event or the ministry directly.</p>
</div>
</div>
<label className="block text-sm font-medium mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<div className="border rounded-xl p-5 bg-white shadow-sm">
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded px-3 py-2 mb-1"
/>
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<button
disabled={loading}
onClick={submit}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Starting checkout...":"Donate"}</button>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded-lg px-3 py-2 mb-1 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
<p className="text-xs text-gray-500 mb-4">Minimum donation is R15.</p>
<button
disabled={loading}
onClick={submit}
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
>{loading?"Starting checkout...":"Donate"}</button>
</div>
</div>
);
}
+10 -4
View File
@@ -4,6 +4,7 @@ import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -130,9 +131,14 @@ function FormsContent() {
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Attendee forms</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
<div className="flex items-center justify-between mb-4 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">
<FileText className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Attendee forms</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
</div>
{loading && <div className="text-sm text-gray-600 mb-2">Loading</div>}
@@ -214,7 +220,7 @@ function FormsContent() {
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
+70 -36
View File
@@ -6,7 +6,9 @@ import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
@@ -672,20 +674,23 @@ export default function UserDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h1 className="text-2xl font-semibold">Welcome{user ? `, ${user.name}` : ""}</h1>
<div className="max-w-6xl mx-auto w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
</div>
@@ -695,16 +700,21 @@ export default function UserDashboardPage() {
{loading && <p className="text-gray-500 text-sm mb-3">Loading</p>}
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">My Registrations</h2>
<label className="text-sm flex items-center gap-2">
<section className="mb-6 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Registrations</h2>
</div>
<label className="text-sm flex items-center gap-2 text-gray-600">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} />
<span>Show past events</span>
</label>
</div>
<p className="text-sm text-gray-600 mb-3 italic">
Click on registration to edit or make payment
<p className="text-sm text-gray-500 mb-3">
Click on a registration to edit or make a payment
</p>
{registrations.length === 0 ? (
<p className="text-gray-600 text-sm">No registrations yet.</p>
@@ -722,16 +732,20 @@ export default function UserDashboardPage() {
return (
<li
key={r.id}
className={"border rounded p-3 cursor-pointer " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50")}
className={"border rounded-xl p-3 cursor-pointer flex items-start gap-3 " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50 hover:border-brand-200")}
onClick={() => setActiveRegId(r.id)}
>
<div className="flex items-start justify-between gap-3">
<div>
<ApiImage src={r.event?.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="flex items-start justify-between gap-3 flex-1 min-w-0">
<div className="min-w-0">
<div className="font-medium flex items-center gap-2">
<span>{r.event?.title || r.eventId}</span>
<span className="truncate">{r.event?.title || r.eventId}</span>
<EventStatusBadge event={r.event} />
</div>
<div className="text-xs text-gray-600">
{r.event?.startDate && (
<div className="text-xs text-gray-500 mt-0.5">{formatDate(new Date(r.event.startDate))}</div>
)}
<div className="text-xs text-gray-600 mt-1">
Status: <RegistrationStatusBadge status={r.status} />
</div>
{(() => {
@@ -788,6 +802,7 @@ export default function UserDashboardPage() {
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 self-center hidden sm:block" />
</li>
);
})}
@@ -795,16 +810,24 @@ export default function UserDashboardPage() {
)}
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Upcoming Events</h2>
<div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
</div>
{upcomingEvents.length === 0 ? (
<p className="text-gray-600 text-sm">No upcoming events.</p>
) : (
<ul className="grid md:grid-cols-2 gap-3">
<ul className="space-y-3">
{upcomingEvents.map(ev => (
<li key={ev.id} className="border rounded p-4">
<li key={ev.id} className="border rounded-xl p-3 flex gap-3">
<ApiImage src={ev.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="min-w-0 flex-1">
<div className="font-medium flex items-center gap-2">
<span>{ev.title}</span>
<span className="truncate">{ev.title}</span>
<EventStatusBadge event={ev} />
</div>
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
@@ -817,7 +840,7 @@ export default function UserDashboardPage() {
}}
>Print all tickets</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => {
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
emailTickets(ids);
@@ -833,15 +856,21 @@ export default function UserDashboardPage() {
>WhatsApp tickets</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<h2 className="text-xl font-semibold">My Tickets</h2>
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Tickets</h2>
</div>
{tickets.length > 0 && (
<div className="flex flex-wrap gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => selectAll(true)}>Select all</button>
@@ -852,7 +881,7 @@ export default function UserDashboardPage() {
onClick={() => printTickets(tickets.filter(t => selectedIds.includes(t.id)))}
>Print selected</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
disabled={selectedIds.length === 0}
onClick={() => emailTickets(selectedIds)}
>Email selected</button>
@@ -869,7 +898,7 @@ export default function UserDashboardPage() {
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (
<p className="text-gray-600 text-sm">No tickets yet.</p>
) : (
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ul className="grid sm:grid-cols-2 gap-3">
{tickets
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
@@ -878,11 +907,15 @@ export default function UserDashboardPage() {
const variantName = t.registrationOption?.variant?.name;
const type = variantName ? `${optionName}${variantName}` : optionName;
return (
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-white shadow-sm">
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-gray-50">
<div className="flex items-center gap-2">
<ApiImage src={t.event?.picture} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
<span className="font-medium text-sm truncate flex-1">{t.event?.title || t.eventId}</span>
</div>
<div className="flex items-start justify-between">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
<span className="font-medium">{t.event?.title || t.eventId}</span>
<span className="text-xs text-gray-500">Select</span>
</label>
<EventStatusBadge event={t.event} />
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
@@ -894,7 +927,7 @@ export default function UserDashboardPage() {
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
<div className="flex gap-2 pt-1 flex-wrap">
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Email</button>
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1">Email</button>
{user?.phoneNumber && (
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
@@ -906,6 +939,7 @@ export default function UserDashboardPage() {
</ul>
)}
</section>
</div>
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
@@ -932,7 +966,7 @@ export default function UserDashboardPage() {
<div className="font-medium mb-1 flex items-center justify-between">
<span>Registration items</span>
{!editMode && canModifyActive && (
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">Edit</button>
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button>
)}
</div>
{!editMode ? (
@@ -1067,7 +1101,7 @@ export default function UserDashboardPage() {
<div className="flex flex-wrap gap-2 pt-2">
{showFormsButton && (
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
@@ -1083,7 +1117,7 @@ export default function UserDashboardPage() {
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
)}
@@ -1130,7 +1164,7 @@ export default function UserDashboardPage() {
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
{dialog.loading ? (
<div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</div>
@@ -1140,7 +1174,7 @@ export default function UserDashboardPage() {
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end">
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => setDialog({ open: false, message: "", loading: false })}
>OK</button>
</div>
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { Receipt } from "lucide-react";
interface PaymentItem {
id: string;
@@ -74,10 +75,15 @@ export default function UserPaymentsPage() {
return (
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">My Payments</h1>
<div className="flex items-center justify-between mb-4 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">
<Receipt className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">My Payments</h1>
</div>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
@@ -183,7 +189,7 @@ export default function UserPaymentsPage() {
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>{p}</button>
+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>
);
}
}
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
export default function ResetPasswordPage() {
const { token } = useAuth();
@@ -43,10 +44,15 @@ export default function ResetPasswordPage() {
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reset password</h1>
<div className="flex items-center justify-between mb-4 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">
<KeyRound className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Reset password</h1>
</div>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
@@ -96,7 +102,7 @@ export default function ResetPasswordPage() {
<button
type="submit"
disabled={!canSubmit || loading}
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60"
>{loading ? "Saving…" : "Update password"}</button>
<button
type="button"