Files
hope-events/frontend/src/app/events/[id]/page.tsx
T
joshuaandClaude Opus 5.5 286cf09dd8 Add optional donation to registration; fix soldOutLines server crash
The register form now offers a "Donation amount" (suggested amounts,
custom, or none) under the ticket options. The donation is added to the
Yoco checkout as its own line and the webhook records it as a separate
donation payment linked to the registration payment.

Move soldOutLines() out of the "use client" JoinWaitlistButton module
into lib/waitlist.ts so the server-rendered event page can call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:27:47 +02:00

340 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Metadata } from "next";
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 { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { soldOutLines } from "@/lib/waitlist";
import { SponsorSpot } from "@/components/events/SponsorSpot";
import { FunnelTracker } from "@/components/events/FunnelTracker";
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; order?: 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;
waitlistEnabled?: boolean;
allowSponsorships?: boolean;
bundles?: PublicBundle[];
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types";
// 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) {
if (event.waitlistEnabled) {
return (
<div className="space-y-2">
<div className="text-center text-sm font-medium text-red-700">Sold out</div>
<JoinWaitlistButton eventId={event.id} lines={soldOutLines(event.eventOptions || [])} guestsAllowed={event.requiresAuth === false} />
</div>
);
}
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>
);
}
// Next dedupes an identical fetch (same URL + cache options) made during the same
// request, so calling this again from the page component below is free.
async function loadEvent(id: string): Promise<Event | null> {
try {
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const event = await loadEvent(id);
if (!event) return {};
const description = event.description
? event.description.slice(0, 200)
: `${event.title} — ${formatDateTimeRange(event.startDate, event.endDate)}`;
const imageUrl = event.picture ? resolveToApiOrigin(event.picture) : null;
return {
title: event.title,
description,
openGraph: {
title: event.title,
description,
type: "website",
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
...(imageUrl ? { images: [imageUrl] } : {}),
},
};
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const event = await loadEvent(id);
if (!event) notFound();
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">
<FunnelTracker eventId={event.id} stage="view" />
<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>
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
</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>
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
</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">
<BundleOffers bundles={event.bundles} />
{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.order ?? 0) - (b.order ?? 0)).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>
);
}