Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user