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>
509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useMemo, useState } from "react";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { apiFetch } from "@/lib/api";
|
||
|
||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||
function lowStockThreshold(stockLimit: number): number {
|
||
let pct: number;
|
||
if (stockLimit <= 50) pct = 0.20;
|
||
else if (stockLimit <= 200) pct = 0.15;
|
||
else if (stockLimit <= 1000) pct = 0.10;
|
||
else pct = 0.05;
|
||
return Math.round(stockLimit * pct);
|
||
}
|
||
|
||
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; order?: number };
|
||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||
type EventOption = {
|
||
id: string;
|
||
name: string;
|
||
price: number;
|
||
stockLimit?: number;
|
||
availableCount?: number;
|
||
isMainTicket?: boolean;
|
||
earlyBirdTiers?: EarlyBirdTier[];
|
||
variants?: OptionVariant[];
|
||
};
|
||
|
||
type Event = {
|
||
id: string;
|
||
title: string;
|
||
description?: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
registrationDeadline?: string | null;
|
||
price: number;
|
||
picture?: string;
|
||
eventOptions?: EventOption[];
|
||
form?: { isRequired: boolean; fields?: any[] } | null;
|
||
requiresAuth?: boolean;
|
||
};
|
||
|
||
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
|
||
type QtyMap = Record<string, number>;
|
||
|
||
function effectiveUnitForOption(opt: EventOption): number {
|
||
const base = opt.price || 0;
|
||
const tiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers.slice() : [];
|
||
if (tiers.length === 0) return base;
|
||
const now = new Date();
|
||
const applicable = tiers
|
||
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
|
||
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || (t as any).availableCount === undefined || (t as any).availableCount > 0))
|
||
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||
return applicable.length > 0 ? applicable[0].price : base;
|
||
}
|
||
|
||
function effectiveUnitForVariant(opt: EventOption, variant: OptionVariant): number {
|
||
if (variant.price !== null && variant.price !== undefined) return variant.price;
|
||
return effectiveUnitForOption(opt);
|
||
}
|
||
|
||
function nextTierForOption(opt: EventOption): EarlyBirdTier | null {
|
||
const tiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers.slice() : [];
|
||
if (tiers.length === 0) return null;
|
||
const now = new Date();
|
||
const applicable = tiers
|
||
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
|
||
.filter((t) => now < t._d)
|
||
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||
if (applicable.length === 0) return null;
|
||
return applicable[0];
|
||
}
|
||
|
||
function StockBadge({ stockLimit, availableCount }: { stockLimit?: number; availableCount?: number }) {
|
||
if (!stockLimit || stockLimit === 0) return null;
|
||
if (availableCount === undefined) return null;
|
||
if (availableCount <= 0) {
|
||
return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>;
|
||
}
|
||
const threshold = lowStockThreshold(stockLimit);
|
||
if (availableCount <= threshold) {
|
||
return <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{availableCount} remaining</span>;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function QtyControl({
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
max,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
disabled?: boolean;
|
||
max?: number;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||
onClick={() => onChange(Math.max(0, value - 1))}
|
||
disabled={disabled || value <= 0}
|
||
>
|
||
−
|
||
</button>
|
||
<span className="w-6 text-center text-sm font-medium">{value}</span>
|
||
<button
|
||
type="button"
|
||
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||
onClick={() => onChange(value + 1)}
|
||
disabled={disabled || (max !== undefined && max > 0 && value >= max)}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OptionCard({
|
||
opt,
|
||
quantities,
|
||
onQtyChange,
|
||
}: {
|
||
opt: EventOption;
|
||
quantities: QtyMap;
|
||
onQtyChange: (key: string, val: number) => void;
|
||
}) {
|
||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||
|
||
if (hasVariants) {
|
||
const variants = opt.variants!.slice().sort((a, b) => (a.order || 0) - (b.order || 0));
|
||
|
||
// Compute "From R..." display
|
||
const variantPrices = variants.map((v) => effectiveUnitForVariant(opt, v));
|
||
const minPrice = Math.min(...variantPrices);
|
||
const maxPrice = Math.max(...variantPrices);
|
||
const priceLabel = minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
|
||
|
||
return (
|
||
<div className="border rounded-lg overflow-hidden bg-white shadow-sm">
|
||
{/* Option header */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
|
||
<div>
|
||
<div className="font-medium text-sm">{opt.name}</div>
|
||
<div className="text-xs text-gray-500 mt-0.5">{priceLabel}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{isSoldOut ? (
|
||
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
|
||
) : (
|
||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Variant rows */}
|
||
<div className="divide-y">
|
||
{variants.map((variant) => {
|
||
const key = `${opt.id}:${variant.id}`;
|
||
const qty = quantities[key] || 0;
|
||
const unitPrice = effectiveUnitForVariant(opt, variant);
|
||
const variantSoldOut = (variant.stockLimit ?? 0) > 0 && variant.availableCount !== undefined && variant.availableCount <= 0;
|
||
const maxQty = ((variant.stockLimit ?? 0) > 0 && variant.availableCount !== undefined)
|
||
? variant.availableCount
|
||
: undefined;
|
||
|
||
return (
|
||
<div key={variant.id} className="flex items-center justify-between px-4 py-2.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="text-sm">{variant.name}</span>
|
||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
|
||
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
|
||
</div>
|
||
<QtyControl
|
||
value={qty}
|
||
onChange={(v) => onQtyChange(key, v)}
|
||
disabled={!!(isSoldOut || variantSoldOut)}
|
||
max={maxQty}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// No variants — simple row
|
||
const key = opt.id;
|
||
const qty = quantities[key] || 0;
|
||
const tier = nextTierForOption(opt);
|
||
const price = effectiveUnitForOption(opt);
|
||
const maxQty = ((opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined)
|
||
? opt.availableCount
|
||
: undefined;
|
||
|
||
return (
|
||
<div className="flex items-center justify-between border rounded-lg p-3 bg-white shadow-sm">
|
||
<div className="min-w-0 flex-1 mr-3">
|
||
<div className="font-medium text-sm">{opt.name}</div>
|
||
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-2 flex-wrap">
|
||
{tier ? (
|
||
<>
|
||
<span>R{price.toFixed(2)}</span>
|
||
<span className="text-brand-600">
|
||
Early bird — ends {new Date(tier.deadline).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" })}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span>R{price.toFixed(2)}</span>
|
||
)}
|
||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||
</div>
|
||
</div>
|
||
<QtyControl
|
||
value={qty}
|
||
onChange={(v) => onQtyChange(key, v)}
|
||
disabled={!!(isSoldOut)}
|
||
max={maxQty}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function RegisterForm({ event }: { event: Event }) {
|
||
const { token, user } = useAuth();
|
||
const eventId = event.id;
|
||
|
||
const [quantities, setQuantities] = useState<QtyMap>(() => {
|
||
const initial: QtyMap = {};
|
||
(event.eventOptions || []).forEach((o) => {
|
||
if (Array.isArray(o.variants) && o.variants.length > 0) {
|
||
o.variants.forEach((v) => { initial[`${o.id}:${v.id}`] = 0; });
|
||
} else {
|
||
initial[o.id] = 0;
|
||
}
|
||
});
|
||
return initial;
|
||
});
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [registrationId, setRegistrationId] = useState<string | null>(null);
|
||
const [creatingCheckout, setCreatingCheckout] = useState(false);
|
||
const [creatingRegistration, setCreatingRegistration] = useState(false);
|
||
|
||
// Guest details (for unauthenticated free-event registration)
|
||
const [guestName, setGuestName] = useState("");
|
||
const [guestEmail, setGuestEmail] = useState("");
|
||
const [guestPhone, setGuestPhone] = useState("");
|
||
|
||
// Event data is server-rendered, but "now" has to be evaluated in the browser (and this page
|
||
// can be served from a up-to-60s-old cache — see revalidate in page.tsx), so still guard against
|
||
// someone loading a link after registration has actually closed.
|
||
useEffect(() => {
|
||
try {
|
||
const now = new Date();
|
||
const end = event.endDate ? new Date(event.endDate) : null;
|
||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||
if ((deadline && now >= deadline) || (end && now >= end)) {
|
||
window.location.replace('/events');
|
||
}
|
||
} catch {}
|
||
}, [event.endDate, event.registrationDeadline]);
|
||
|
||
const handleQtyChange = (key: string, val: number) => {
|
||
setQuantities((q) => ({ ...q, [key]: val }));
|
||
};
|
||
|
||
const total = useMemo(() => {
|
||
return (event.eventOptions || []).reduce((sum, opt) => {
|
||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||
if (hasVariants) {
|
||
return sum + opt.variants!.reduce((vsum, v) => {
|
||
const qty = quantities[`${opt.id}:${v.id}`] || 0;
|
||
return vsum + qty * effectiveUnitForVariant(opt, v);
|
||
}, 0);
|
||
}
|
||
return sum + (quantities[opt.id] || 0) * effectiveUnitForOption(opt);
|
||
}, 0);
|
||
}, [event, quantities]);
|
||
|
||
const isGuestEligible = !!(event.requiresAuth === false);
|
||
const isLoggedIn = !!user || !!token;
|
||
const showGuestForm = isGuestEligible && !isLoggedIn;
|
||
|
||
const eventSoldOut = useMemo(() => {
|
||
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
|
||
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
|
||
}, [event]);
|
||
|
||
const buildItems = () => {
|
||
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
|
||
Object.entries(quantities).forEach(([key, qty]) => {
|
||
if (qty <= 0) return;
|
||
if (key.includes(":")) {
|
||
const [eventOptionId, variantId] = key.split(":");
|
||
items.push({ eventOptionId, quantity: qty, variantId });
|
||
} else {
|
||
items.push({ eventOptionId: key, quantity: qty });
|
||
}
|
||
});
|
||
return items;
|
||
};
|
||
|
||
const submitRegistration = async () => {
|
||
const items = buildItems();
|
||
if (items.length === 0) {
|
||
setError("Select at least one ticket.");
|
||
return;
|
||
}
|
||
|
||
if (isLoggedIn) {
|
||
if (!token) {
|
||
setError("Session expired — please log in again.");
|
||
window.location.href = `/login?redirect=/register/${eventId}`;
|
||
return;
|
||
}
|
||
try {
|
||
setError(null);
|
||
setCreatingRegistration(true);
|
||
const res = await apiFetch<{ id: string }>("/api/registrations", {
|
||
method: "POST",
|
||
body: { eventId, options: items },
|
||
authToken: token,
|
||
});
|
||
if (res?.id) {
|
||
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
|
||
if (hasForm) {
|
||
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
|
||
} else {
|
||
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((total || 0).toFixed(2))}`;
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || "Registration failed");
|
||
} finally {
|
||
setCreatingRegistration(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!showGuestForm) {
|
||
setError("Please log in to register for this event.");
|
||
window.location.href = `/login?redirect=/register/${eventId}`;
|
||
return;
|
||
}
|
||
|
||
if (!guestName.trim()) { setError("Please enter your name."); return; }
|
||
if (!guestEmail.trim() || !guestEmail.includes("@")) { setError("Please enter a valid email address."); return; }
|
||
|
||
try {
|
||
setError(null);
|
||
setCreatingRegistration(true);
|
||
const res = await apiFetch<{ id: string }>("/api/registrations", {
|
||
method: "POST",
|
||
body: { eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined },
|
||
});
|
||
if (res?.id) {
|
||
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
|
||
if (hasForm) {
|
||
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
|
||
} else {
|
||
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=0.00`;
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || "Registration failed");
|
||
} finally {
|
||
setCreatingRegistration(false);
|
||
}
|
||
};
|
||
|
||
const createYocoCheckout = async () => {
|
||
if (!token || !registrationId) return;
|
||
try {
|
||
setCreatingCheckout(true);
|
||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
|
||
method: "POST",
|
||
body: {
|
||
registrationId,
|
||
successUrl: window.location.origin + "/payment/success",
|
||
cancelUrl: window.location.origin + "/payment/cancel",
|
||
failureUrl: window.location.origin + "/payment/failure",
|
||
},
|
||
authToken: token,
|
||
});
|
||
window.open(res.redirectUrl, "_blank");
|
||
} catch (e: any) {
|
||
setError(e?.message || "Failed to create checkout");
|
||
} finally {
|
||
setCreatingCheckout(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<h1 className="text-2xl font-semibold mb-4">Register for {event.title}</h1>
|
||
|
||
{eventSoldOut && (
|
||
<div className="mb-4 p-4 border rounded-lg bg-red-50 border-red-200 text-sm text-red-800 font-medium">
|
||
This event is sold out. Registration is no longer available.
|
||
</div>
|
||
)}
|
||
|
||
{!isLoggedIn && !isGuestEligible && (
|
||
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-800">
|
||
You need to <a href={`/login?redirect=/register/${eventId}`} className="font-semibold underline">log in</a> or <a href={`/register?redirect=/register/${eventId}`} className="font-semibold underline">create an account</a> to register for this event.
|
||
</div>
|
||
)}
|
||
|
||
{showGuestForm && (
|
||
<div className="mb-6 border rounded-xl p-4 bg-white shadow-sm">
|
||
<div className="text-base font-semibold mb-1">Your details</div>
|
||
<p className="text-sm text-gray-500 mb-3">
|
||
No account needed for this event.{" "}
|
||
<a href={`/login?redirect=/register/${eventId}`} className="text-brand-600 hover:underline">Log in</a> if you already have one.
|
||
</p>
|
||
<div className="space-y-2">
|
||
<input
|
||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||
placeholder="Full name *"
|
||
value={guestName}
|
||
onChange={e => setGuestName(e.target.value)}
|
||
required
|
||
/>
|
||
<input
|
||
type="email"
|
||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||
placeholder="Email address *"
|
||
value={guestEmail}
|
||
onChange={e => setGuestEmail(e.target.value)}
|
||
required
|
||
/>
|
||
<input
|
||
type="tel"
|
||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||
placeholder="Phone number (optional)"
|
||
value={guestPhone}
|
||
onChange={e => setGuestPhone(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
{(event.eventOptions || []).map((opt) => (
|
||
<OptionCard
|
||
key={opt.id}
|
||
opt={opt}
|
||
quantities={quantities}
|
||
onQtyChange={handleQtyChange}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{(() => {
|
||
// Collect all upcoming early-bird tiers across all options
|
||
const now = new Date();
|
||
const upcomingTiers = (event.eventOptions || []).flatMap(opt =>
|
||
(opt.earlyBirdTiers || [])
|
||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||
.filter((t: any) => t.deadline > now)
|
||
).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
|
||
if (upcomingTiers.length === 0) return null;
|
||
const soonest = upcomingTiers[0].deadline as Date;
|
||
const dateStr = soonest.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||
return (
|
||
<div className="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
||
<strong>Early bird pricing notice:</strong> Reduced early-bird prices are available until {dateStr}. Prices are subject to deadline and availability — your price is locked at registration but payment must be completed before the deadline to guarantee the early-bird rate.
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<div className="mt-6 flex items-center justify-between">
|
||
<div className="text-lg font-semibold">Total: R{total.toFixed(2)}</div>
|
||
{!registrationId ? (
|
||
eventSoldOut ? (
|
||
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
|
||
Sold Out
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={submitRegistration}
|
||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
|
||
className="bg-brand-600 text-white px-4 py-2 rounded hover:bg-brand-700 disabled:opacity-60"
|
||
>
|
||
{creatingRegistration ? "Registering..." : "Register"}
|
||
</button>
|
||
)
|
||
) : (
|
||
<button
|
||
onClick={createYocoCheckout}
|
||
disabled={creatingCheckout}
|
||
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||
>
|
||
{creatingCheckout ? "Creating checkout..." : "Pay with Yoco"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
|
||
</>
|
||
);
|
||
}
|