Events and the organisation profile now have an address, with a "Directions" link and an embedded Google Maps view (no API key required) shown on event pages, event cards, and the Contact page. New events default their location to the org's configured address.
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
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 (
|
||
<ContactButton
|
||
contactName={event.contactName}
|
||
contactPhone={event.contactPhone}
|
||
contactEmail={event.contactEmail}
|
||
className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors"
|
||
/>
|
||
);
|
||
}
|
||
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 (
|
||
<button disabled className="w-full bg-gray-200 text-gray-600 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium" title="Registration not yet open">
|
||
Opens {goLive?.toLocaleString()}
|
||
</button>
|
||
);
|
||
}
|
||
if (closed) {
|
||
return (
|
||
<button disabled className="w-full bg-gray-200 text-gray-600 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium" title="Registration closed">
|
||
Registration closed
|
||
</button>
|
||
);
|
||
}
|
||
if (eventSoldOut) {
|
||
return (
|
||
<button disabled className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium">
|
||
Sold Out
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<a href={`/register/${event.id}`} className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors">
|
||
Register
|
||
</a>
|
||
);
|
||
}
|
||
|
||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
let event: Event;
|
||
try {
|
||
event = await apiFetch<Event>(`/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 (
|
||
<div className="min-h-screen flex flex-col">
|
||
<Navbar />
|
||
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
|
||
<div className="grid lg:grid-cols-3 gap-8">
|
||
<div className="lg:col-span-2 space-y-6">
|
||
{event.picture ? (
|
||
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded-xl" />
|
||
) : (
|
||
<div className="w-full aspect-video rounded-xl bg-brand-50 flex items-center justify-center">
|
||
<Calendar className="w-12 h-12 text-brand-200" />
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
|
||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
|
||
<Calendar className="w-4 h-4 shrink-0" />
|
||
{formatDateTimeRange(event.startDate, event.endDate)}
|
||
</p>
|
||
<div className="mt-3">
|
||
<ClientActions event={event} />
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||
|
||
{event.location && (
|
||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
|
||
<LocationMap address={event.location} />
|
||
</div>
|
||
)}
|
||
|
||
{event.attachments && event.attachments.length > 0 && (
|
||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Paperclip className="w-4 h-4 text-gray-500" />
|
||
<h2 className="text-base font-semibold text-gray-900">Downloads</h2>
|
||
</div>
|
||
<ul className="space-y-1.5">
|
||
{event.attachments.map((att) => (
|
||
<li key={att.id}>
|
||
<a className="text-brand-600 hover:underline text-sm" href={att.url} target="_blank" rel="noopener noreferrer">
|
||
{att.originalName}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tickets are shown in the sticky card on desktop; repeat here for mobile below the fold */}
|
||
<div className="border rounded-xl p-5 bg-white shadow-sm lg:hidden">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Ticket className="w-4 h-4 text-gray-500" />
|
||
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
|
||
</div>
|
||
<TicketList event={event} />
|
||
<div className="mt-4">
|
||
<RegisterCta event={event} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hidden lg:block">
|
||
<div className="border rounded-xl p-5 bg-white shadow-sm sticky top-20">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Ticket className="w-4 h-4 text-gray-500" />
|
||
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
|
||
</div>
|
||
<TicketList event={event} />
|
||
<div className="mt-4">
|
||
<RegisterCta event={event} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TicketList({ event }: { event: Event }) {
|
||
const options = event.eventOptions || [];
|
||
if (options.length === 0) {
|
||
return (
|
||
<div className="text-center py-6">
|
||
<Sparkles className="w-6 h-6 text-gray-300 mx-auto mb-1.5" />
|
||
<p className="text-sm text-gray-400">Free entry — no tickets required.</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="space-y-2">
|
||
{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 (
|
||
<div key={opt.id} className="border rounded-lg overflow-hidden">
|
||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
|
||
<span className="font-medium text-sm">{opt.name}</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">{priceLabel}</span>
|
||
{soldOut && <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>}
|
||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||
</div>
|
||
</div>
|
||
<ul className="divide-y">
|
||
{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 (
|
||
<li key={v.id} className="flex items-center justify-between px-3 py-2 text-sm text-gray-700">
|
||
<span>{v.name}</span>
|
||
<div className="flex items-center gap-2">
|
||
<span>{vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`}</span>
|
||
{vSoldOut && <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>}
|
||
{vNearlyOut && !vSoldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{v.availableCount} remaining</span>}
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
|
||
<span className="font-medium">{opt.name}</span>
|
||
<div className="flex items-center gap-2">
|
||
{activeTiers.length > 0 ? (
|
||
<>
|
||
<span className="text-brand-700 font-medium">R{displayPrice.toFixed(2)}</span>
|
||
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
|
||
<span className="text-xs text-brand-600">Early bird</span>
|
||
</>
|
||
) : (
|
||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
|
||
)}
|
||
{soldOut && <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>}
|
||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|