Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
@@ -0,0 +1,508 @@
"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-indigo-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-indigo-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-indigo-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-indigo-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-indigo-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-blue-600 text-white px-4 py-2 rounded 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>}
</>
);
}