import { notFound } from "next/navigation"; import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; import ClientActions from "@/app/events/[id]/ClientActions"; import { ContactButton } from "@/components/events/ContactButton"; import { LocationMap } from "@/components/events/LocationMap"; import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react"; export const revalidate = 60; type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number }; type EventOption = { id: string; name: string; price: number; stockLimit?: number; availableCount?: number; isMainTicket?: boolean; earlyBirdTiers?: { id: string; deadline: string; price: number; stockLimit?: number }[]; variants?: OptionVariant[]; }; type EventAttachment = { id: string; originalName: string; url: string; size: number; mimeType: string }; type Event = { id: string; title: string; description?: string; startDate: string; endDate: string; registrationDeadline?: string | null; goLiveAt?: string; price: number; picture?: string; eventOptions?: EventOption[]; attachments?: EventAttachment[]; requiresAuth?: boolean; requiresRegistration?: boolean; contactName?: string | null; contactPhone?: string | null; contactEmail?: string | null; location?: string | null; }; import { apiFetch, ApiError } from "@/lib/api"; import { ApiImage } from "@/components/shared/ApiImage"; import { formatDateTimeRange } from "@/lib/date"; // Tiered low-stock threshold: larger events use a smaller percentage. // Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made // a 10-ticket event warn at 20% when the stated threshold was 15%). 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); } function RegisterCta({ event }: { event: Event }) { if (event.requiresRegistration === false) { return ( ); } const now = new Date(); const end = new Date(event.endDate); const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null; const notYetOpen = goLive ? now < goLive : false; const closed = (deadline ? now >= deadline : false) || now >= end; const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0); const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0); if (notYetOpen) { return ( ); } if (closed) { return ( ); } if (eventSoldOut) { return ( ); } return ( Register ); } export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; let event: Event; try { event = await apiFetch(`/api/events/${id}`, { nextOptions: { next: { revalidate } } }); } catch (e) { // The event endpoint 404s for missing, inactive, or not-yet-live events — // render the standard not-found page instead of crashing. if (e instanceof ApiError && e.status === 404) notFound(); throw e; } return (
{event.picture ? ( ) : (
)}

{event.title}

{formatDateTimeRange(event.startDate, event.endDate)}

{event.description}

{event.location && (

Location

)} {event.attachments && event.attachments.length > 0 && (

Downloads

)} {/* Tickets are shown in the sticky card on desktop; repeat here for mobile below the fold */}

Tickets

Tickets

); } function TicketList({ event }: { event: Event }) { const options = event.eventOptions || []; if (options.length === 0) { return (

Free entry — no tickets required.

); } return (
{options.map((opt) => { const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0; const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0; const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0); if (hasVariants) { const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`; return (
{opt.name}
{priceLabel} {soldOut && Sold out} {nearlyOut && !soldOut && {opt.availableCount} remaining}
    {opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => { const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price; const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0; const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0); return (
  • {v.name}
    {vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`} {vSoldOut && Sold out} {vNearlyOut && !vSoldOut && {v.availableCount} remaining}
  • ); })}
); } // Early bird pricing display const now = new Date(); const activeTiers = (opt.earlyBirdTiers || []) .filter((t) => now < new Date(t.deadline)) .sort((a, b) => a.price - b.price); const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price; return (
{opt.name}
{activeTiers.length > 0 ? ( <> R{displayPrice.toFixed(2)} R{opt.price.toFixed(2)} Early bird ) : ( {displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`} )} {soldOut && Sold out} {nearlyOut && !soldOut && {opt.availableCount} remaining}
); })}
); }