"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; 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 Sold out; } const threshold = lowStockThreshold(stockLimit); if (availableCount <= threshold) { return {availableCount} remaining; } return null; } function QtyControl({ value, onChange, disabled, max, }: { value: number; onChange: (v: number) => void; disabled?: boolean; max?: number; }) { return (
{value}
); } 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 (
{/* Option header */}
{opt.name}
{priceLabel}
{isSoldOut ? ( Sold out ) : ( )}
{/* Variant rows */}
{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 (
{variant.name} R{unitPrice.toFixed(2)}
onQtyChange(key, v)} disabled={!!(isSoldOut || variantSoldOut)} max={maxQty} />
); })}
); } // 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 (
{opt.name}
{tier ? ( <> R{price.toFixed(2)} Early bird — ends {new Date(tier.deadline).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" })} ) : ( R{price.toFixed(2)} )}
onQtyChange(key, v)} disabled={!!(isSoldOut)} max={maxQty} />
); } export default function RegisterForm({ event }: { event: Event }) { const { token, user } = useAuth(); const eventId = event.id; const [quantities, setQuantities] = useState(() => { 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(null); const [registrationId, setRegistrationId] = useState(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 ( <>

Register for {event.title}

{eventSoldOut && (
This event is sold out. Registration is no longer available.
)} {!isLoggedIn && !isGuestEligible && (
You need to log in or create an account to register for this event.
)} {showGuestForm && (
Your details

No account needed for this event.{" "} Log in if you already have one.

setGuestName(e.target.value)} required /> setGuestEmail(e.target.value)} required /> setGuestPhone(e.target.value)} />
)}
{(event.eventOptions || []).map((opt) => ( ))}
{(() => { // 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 (
Early bird pricing notice: 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.
); })()}
Total: R{total.toFixed(2)}
{!registrationId ? ( eventSoldOut ? ( ) : ( ) ) : ( )}
{error &&

{error}

} ); }