Files
hope-events/frontend/src/app/register/[eventId]/RegisterForm.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 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>
2026-08-06 15:00:10 +02:00

509 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>}
</>
);
}