commit ff743a139769af2b26a459838d3b8809abf254a0 Author: KabundiNjuki Date: Mon Oct 5 16:40:36 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a69ace9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +build +.env +.env.* +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5150f2 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# lukenya-university-marathon + +This is a [Next.js](https://nextjs.org) project bootstrapped with [v0](https://v0.app). + +## Built with v0 + +This repository is linked to a [v0](https://v0.app) project. You can continue developing by visiting the link below -- start new chats to make changes, and v0 will push commits directly to this repo. Every merge to `main` will automatically deploy. + +[Continue working on v0 →](https://v0.app/chat/projects/prj_4drPsrNVDgEwcruGKroiMoJrP8cl) + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +## Learn More + +To learn more, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [v0 Documentation](https://v0.app/docs) - learn about v0 and how to use it. + + + +## Backend connection + +Race, price, edition and prize data come from the Frappe backend (`lukenya_marathon` app). +Nothing about races is hard-coded in the pages any more. + +- `lib/lukenya-api.ts`: server-only calls to Frappe. Pages refresh their data every 60 seconds, + or immediately when the backend calls `POST /api/revalidate`. +- `lib/event-types.ts`: the data shapes the backend returns. Treat these as the contract. +- The browser never calls Frappe directly. It posts to this site's routes, which forward to Frappe: + - `POST /api/register` → `api.create_registration` + - `POST /api/payment/start` → `payments.start_payment` (returns the Paystack checkout link) + - `GET /api/payment/status?reference=…` → `payments.check_payment_status` +- Paystack sends the runner back to `/payment/return`, which waits for the server to verify the payment. + +If `api.get_public_event` isn't available yet, the site falls back to the older +`api.get_event_categories` endpoint. Set `FRAPPE_EDITION` so that fallback only shows one edition. + +Environment variables are listed in `.env.example`. diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..b80ae44 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,283 @@ +import Link from 'next/link' +import { TreePine, MapPin, Users, Target, Heart, Globe, GraduationCap } from 'lucide-react' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' + +const missionStats = [ + { value: '10,000,000', label: 'Trees Committed', icon: TreePine }, + { value: 'ASAL', label: 'Dryland Regions', icon: MapPin }, + { value: '780+', label: 'Runners So Far', icon: Users }, +] + +const timeline = [ + { + year: '2022', + title: 'The Beginning', + description: 'Lukenya University Marathon launches with 150 runners and a bold mission: plant 10 million trees in Kenya\'s ASAL regions.', + }, + { + year: '2023', + title: 'Growing Momentum', + description: '280 runners join the cause. Corporate relay category introduced. First Environmental Partner signs on.', + }, + { + year: '2024', + title: 'Record Participation', + description: '350+ finishers. Wheelchair category sees record entries. Partnership with local reforestation NGOs expands.', + }, + { + year: '2025', + title: 'The Future', + description: 'Targeting 500+ participants. New university partnerships. Expanded tree-planting verification programme.', + }, +] + +const asalInfo = [ + { + icon: Globe, + title: 'What are ASAL Regions?', + description: 'Arid and Semi-Arid Lands cover over 80% of Kenya\'s land mass. These dryland ecosystems are home to millions of Kenyans and are most vulnerable to climate change.', + }, + { + icon: TreePine, + title: 'Why Trees Matter', + description: 'Reforestation in ASAL regions combats desertification, improves soil quality, provides sustainable livelihoods, and sequesters carbon dioxide from the atmosphere.', + }, + { + icon: Target, + title: 'Our Approach', + description: 'We partner with verified reforestation NGOs to plant indigenous tree species. Each tree is GPS-tracked and monitored for survival over 3 years.', + }, +] + +export default function AboutPage() { + return ( +
+ + + {/* Page Hero */} +
+
+ + Our Story + +

+ Kenya's Only Environmental Marathon +

+

+ More than a race. A movement to restore Kenya's dryland ecosystems, one entry at a time. +

+
+
+ + {/* Mission Stats */} +
+
+
+ {missionStats.map((stat, index) => ( +
+ +

{stat.value}

+

{stat.label}

+
+ ))} +
+
+
+ + {/* 10M Trees Mission */} +
+
+
+ + + The 10 Million Trees Mission + +

+ Run for Kenya. Plant a Future. +

+
+ +
+

+ The Lukenya University Marathon was founded with a simple but ambitious goal: to become Kenya's first and only marathon with a verified environmental mission. +

+

+ Every entry fee directly funds tree planting in Kenya's ASAL (Arid and Semi-Arid Land) regions — the dryland ecosystems that cover over 80% of our country and are most threatened by climate change. We don't just promise impact; we verify it. Each tree is GPS-tracked, monitored for survival, and reported in our annual impact report. +

+

+ Our target: 10 million trees. Every entry brings Kenya one step closer to a greener future. +

+
+
+
+ + {/* ASAL Information */} +
+
+
+ + Understanding the Impact + +

+ Why ASAL Regions? +

+
+ +
+ {asalInfo.map((item, index) => ( +
+ +

{item.title}

+

{item.description}

+
+ ))} +
+
+
+ + {/* Timeline */} +
+
+
+ + Our Journey + +

+ Growing Every Year +

+
+ +
+ {timeline.map((item, index) => ( +
+
+
+ {item.year} +
+ {index < timeline.length - 1 && ( +
+ )} +
+
+

{item.title}

+

{item.description}

+
+
+ ))} +
+
+
+ + {/* About Lukenya University */} +
+
+
+ + + Our Home + +

+ Lukenya University +

+
+ +
+

+ Lukenya University, located in Machakos County, Kenya, serves as the proud host of the Lukenya University Marathon. The university's commitment to environmental sustainability and community development aligns perfectly with the marathon's mission. +

+

+ The scenic campus and surrounding terrain provide a challenging yet beautiful course that showcases the natural beauty of Machakos County — while reminding every runner why protecting Kenya's landscapes matters. +

+
+
+ + Machakos County, Kenya +
+
+ + University-Verified Programme +
+
+
+
+
+ + {/* Values Section */} +
+
+
+ + What We Stand For + +

+ Our Values +

+
+ +
+
+
+ +
+

Environmental Impact

+

Every race entry contributes to verified reforestation.

+
+
+
+ +
+

Inclusivity

+

7 categories welcoming runners of all abilities.

+
+
+
+ +
+

Transparency

+

GPS-tracked trees and published impact reports.

+
+
+
+ +
+

Community

+

Building a movement of runners who care.

+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Join the Mission +

+

+ Run with purpose. Plant a future. Be part of Kenya's environmental marathon movement. +

+
+ + REGISTER NOW + + + BECOME A PARTNER + +
+
+
+ +
+
+ ) +} diff --git a/app/api/legal/route.ts b/app/api/legal/route.ts new file mode 100644 index 0000000..6e2ebe9 --- /dev/null +++ b/app/api/legal/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' +import { METHODS, frappeCall } from '@/lib/lukenya-api' + +// Current published legal documents (terms, indemnity, privacy, optional consents). +export async function GET() { + try { + const docs = await frappeCall(METHODS.legalDocuments, { revalidate: 60 }) + return NextResponse.json(docs || [], { headers: { 'Cache-Control': 'public, max-age=60' } }) + } catch { + return NextResponse.json([], { status: 503 }) + } +} diff --git a/app/api/my-race/route.ts b/app/api/my-race/route.ts new file mode 100644 index 0000000..4dcc26a --- /dev/null +++ b/app/api/my-race/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { FrappeError, METHODS, frappeCall } from '@/lib/lukenya-api' + +// POST { token } → the runner's entry. POST { email } → email a fresh My Race link. +// Tokens travel in the request body, not the URL, so they don't end up in access logs. +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})) + try { + if (typeof body.token === 'string' && body.token) { + const entry = await frappeCall>(METHODS.myRace, { args: { token: body.token }, post: true }) + return NextResponse.json(entry, { headers: { 'Cache-Control': 'no-store' } }) + } + if (typeof body.email === 'string' && body.email.includes('@')) { + const result = await frappeCall<{ message: string }>(METHODS.myRaceLink, { + args: { email: body.email.trim().toLowerCase() }, + post: true, + }) + return NextResponse.json(result) + } + return NextResponse.json({ error: 'Enter the email you registered with.' }, { status: 400 }) + } catch (err) { + const status = err instanceof FrappeError && err.status < 500 ? err.status : 502 + return NextResponse.json({ error: (err as Error).message }, { status }) + } +} diff --git a/app/api/payment/start/route.ts b/app/api/payment/start/route.ts new file mode 100644 index 0000000..9356762 --- /dev/null +++ b/app/api/payment/start/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { FrappeError, METHODS, frappeCall, registrationEnabled } from '@/lib/lukenya-api' + +// Asks the backend to open a Paystack checkout for a saved entry and returns its link. +export async function POST(req: Request) { + if (!registrationEnabled()) { + return NextResponse.json({ error: 'Online payment is not open yet.' }, { status: 403 }) + } + const body = await req.json().catch(() => ({})) + const registration = typeof body.registration === 'string' ? body.registration : '' + if (!registration) return NextResponse.json({ error: 'No entry to pay for.' }, { status: 400 }) + + try { + const result = await frappeCall<{ authorization_url: string; reference: string }>(METHODS.startPayment, { + args: { + registration, + payment_token: typeof body.payment_token === 'string' ? body.payment_token : undefined, + channel: body.channel === 'card' ? 'card' : 'mpesa', + }, + post: true, + }) + if (!result?.authorization_url) throw new Error('The payment page could not be opened.') + return NextResponse.json(result) + } catch (err) { + const status = err instanceof FrappeError && err.status < 500 ? 400 : 502 + return NextResponse.json({ error: (err as Error).message }, { status }) + } +} diff --git a/app/api/payment/status/route.ts b/app/api/payment/status/route.ts new file mode 100644 index 0000000..4d85994 --- /dev/null +++ b/app/api/payment/status/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { FrappeError, METHODS, frappeCall } from '@/lib/lukenya-api' + +// Polled by /payment/return. The backend checks with Paystack itself before answering, +// so the browser being sent back here never confirms a payment on its own. +export async function GET(req: Request) { + const reference = new URL(req.url).searchParams.get('reference') || '' + if (!/^[A-Za-z0-9._-]{4,100}$/.test(reference)) { + return NextResponse.json({ error: 'Missing or invalid payment reference.' }, { status: 400 }) + } + try { + const result = await frappeCall>(METHODS.paymentStatus, { args: { reference } }) + return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } }) + } catch (err) { + const status = (err as FrappeError).status + if (status === 404) return NextResponse.json({ error: 'We could not find this payment.' }, { status: 404 }) + // The server re-checks with Paystack in the background; keep the runner on the waiting screen. + return NextResponse.json({ status: 'Pending' }, { headers: { 'Cache-Control': 'no-store' } }) + } +} diff --git a/app/api/register/route.ts b/app/api/register/route.ts new file mode 100644 index 0000000..8df7a38 --- /dev/null +++ b/app/api/register/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { FrappeError, METHODS, frappeCall, registrationEnabled } from '@/lib/lukenya-api' + +// The browser posts the form here; this route forwards it to Frappe. The backend +// re-validates everything and decides the price, so nothing here is trusted for money. + +const ALLOWED = [ + 'event_edition', 'race_category', 'residency', 'first_name', 'last_name', 'id_number', + 'date_of_birth', 'gender', 'nationality', 'email', 'phone', 'whatsapp', 'shirt_size', + 'emergency_contact_name', 'emergency_contact_phone', 'guardian_name', 'guardian_phone', + 'indemnity_accepted', 'communication_consent', 'preferred_payment', 'emergency_contact_relationship', + 'support_request', 'consents', +] as const + +const REQUIRED = [ + 'race_category', 'first_name', 'last_name', 'email', 'phone', + 'emergency_contact_name', 'emergency_contact_phone', +] as const + +export async function POST(req: Request) { + if (!registrationEnabled()) { + return NextResponse.json({ error: 'Online registration is not open yet.' }, { status: 403 }) + } + + let body: Record + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'The form data could not be read.' }, { status: 400 }) + } + + const payload: Record = {} + for (const key of ALLOWED) { + const value = body[key] + if (value === undefined || value === null) continue + payload[key] = typeof value === 'string' ? value.trim().slice(0, key === 'support_request' ? 1000 : 500) : value + } + payload.event_edition = payload.event_edition || process.env.FRAPPE_EDITION + + const missing = REQUIRED.filter((k) => !payload[k]) + if (!payload.event_edition) missing.unshift('race edition' as never) + if (payload.indemnity_accepted !== true) missing.push('indemnity acceptance' as never) + if (missing.length) { + return NextResponse.json({ error: `Please complete: ${missing.join(', ').replace(/_/g, ' ')}.` }, { status: 400 }) + } + + try { + const result = await frappeCall<{ + registration: string + amount_due: number + currency: string + payment_token?: string + }>(METHODS.createRegistration, { args: { payload }, post: true }) + return NextResponse.json({ + registration: result.registration, + amount_due: result.amount_due, + currency: result.currency, + payment_token: result.payment_token, + }) + } catch (err) { + const status = err instanceof FrappeError && err.status < 500 ? 400 : 502 + return NextResponse.json({ error: (err as Error).message }, { status }) + } +} diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 0000000..80c071c --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { revalidatePath } from 'next/cache' + +// Called by the Frappe backend after race data changes, so the site updates at once +// instead of within the 60-second refresh window. +export async function POST(req: Request) { + const secret = process.env.REVALIDATE_SECRET + if (!secret || req.headers.get('x-revalidate-secret') !== secret) { + return NextResponse.json({ error: 'Not allowed.' }, { status: 401 }) + } + revalidatePath('/', 'layout') + return NextResponse.json({ revalidated: true, at: new Date().toISOString() }) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..0dbea88 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,97 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + /* Lukenya University Marathon Design System - V3 */ + --green-dark: #006838; + --green-mid: #3BB34A; + --yellow: #FFF100; + --gold: #F0C000; + --dark: #111518; + --gray-light: #F9FAFB; + --white: #FFFFFF; + --text-muted: #687279; + --btn-text: #714508; + + /* Semantic tokens */ + --background: var(--white); + --foreground: var(--dark); + --card: var(--white); + --card-foreground: var(--dark); + --popover: var(--white); + --popover-foreground: var(--dark); + --primary: var(--green-dark); + --primary-foreground: var(--white); + --secondary: var(--gray-light); + --secondary-foreground: var(--green-dark); + --muted: var(--gray-light); + --muted-foreground: var(--text-muted); + --accent: var(--yellow); + --accent-foreground: var(--btn-text); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: var(--white); + --border: #e5e7eb; + --input: #e5e7eb; + --ring: var(--green-dark); + --radius: 0.375rem; +} + +@theme inline { + --font-sans: 'Quicksand', sans-serif; + --font-display: 'Antonio', sans-serif; + --font-heading: 'Raleway', sans-serif; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 2px); + --radius-md: var(--radius); + --radius-lg: calc(var(--radius) + 2px); + --radius-xl: calc(var(--radius) + 6px); + + /* Custom colors for direct use */ + --color-green-dark: var(--green-dark); + --color-green-mid: var(--green-mid); + --color-yellow: var(--yellow); + --color-gold: var(--gold); + --color-dark: var(--dark); + --color-gray-light: var(--gray-light); + --color-text-muted: var(--text-muted); + --color-btn-text: var(--btn-text); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Registration Status Badge Pulse Animation */ +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.animate-pulse-dot { + animation: pulse-dot 2s infinite; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..93a5dde --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,65 @@ +import type { Metadata, Viewport } from 'next' +import { Antonio, Raleway, Quicksand } from 'next/font/google' +import { Analytics } from '@vercel/analytics/next' +import './globals.css' +import { EventProvider } from '@/components/event-provider' +import { getPublicEvent, registrationEnabled } from '@/lib/lukenya-api' + +// Race data comes from the backend; refresh it at most once a minute. +export const revalidate = 60 + +const antonio = Antonio({ + subsets: ['latin'], + weight: ['400', '700'], + variable: '--font-antonio', + display: 'swap', +}) + +const raleway = Raleway({ + subsets: ['latin'], + weight: ['400', '600', '700'], + variable: '--font-raleway', + display: 'swap', +}) + +const quicksand = Quicksand({ + subsets: ['latin'], + weight: ['400', '500', '600'], + variable: '--font-quicksand', + display: 'swap', +}) + +export const metadata: Metadata = { + title: '10 Million Trees Marathon | Lukenya University', + description: 'Africa\'s only university-backed marathon with a verified reforestation mission. Running, cycling and virtual races, competitive prize money and certified timing.', + keywords: ['marathon', 'Kenya', 'Lukenya University', 'environmental', 'running', 'ASAL', 'reforestation', '10 million trees', 'Machakos'], + openGraph: { + title: '10 Million Trees Marathon | Lukenya University', + description: 'Run for Kenya. Plant a Future. Africa\'s only university-backed environmental marathon.', + type: 'website', + }, +} + +export const viewport: Viewport = { + themeColor: '#006838', + width: 'device-width', + initialScale: 1, +} + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + const event = await getPublicEvent() + return ( + + + + {children} + + {process.env.NODE_ENV === 'production' && } + + + ) +} diff --git a/app/legal/page.tsx b/app/legal/page.tsx new file mode 100644 index 0000000..773abb8 --- /dev/null +++ b/app/legal/page.tsx @@ -0,0 +1,83 @@ +import type { Metadata } from 'next' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { RichText } from '@/components/rich-text' +import { METHODS, frappeCall } from '@/lib/lukenya-api' +import { formatEventDate } from '@/lib/event-types' + +export const revalidate = 60 + +export const metadata: Metadata = { + title: 'Terms and Privacy | 10 Million Trees Marathon', + description: 'Participation terms, indemnity, privacy notice and consent documents for the Lukenya University Marathon.', +} + +interface LegalDoc { + name: string + document_type: string + slug: string + version: string + title: string + summary: string | null + body: string + effective_from: string | null + required: boolean + applies_to: string +} + +async function documents(): Promise { + try { + return (await frappeCall(METHODS.legalDocuments, { revalidate: 60 })) || [] + } catch { + return [] + } +} + +export default async function LegalPage() { + const docs = await documents() + return ( +
+ +
+
+

Terms, Privacy and Consent

+

The documents every runner agrees to when they enter. Each shows its version and when it took effect.

+
+
+
+
+ {docs.length === 0 ? ( +

The documents couldn't be loaded just now. Please try again shortly.

+ ) : ( + <> + + {docs.map((d) => ( +
+

{d.title}

+

+ Version {d.version} + {d.effective_from ? ` · effective ${formatEventDate(d.effective_from.slice(0, 10))}` : ''} + {d.required ? '' : ' · optional'} + {d.applies_to === 'Under 18 only' ? ' · for runners under 18' : ''} +

+ +
+ ))} + + )} +
+
+
+
+ ) +} diff --git a/app/my-race/my-race-view.tsx b/app/my-race/my-race-view.tsx new file mode 100644 index 0000000..8adbd3c --- /dev/null +++ b/app/my-race/my-race-view.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { Loader2 } from 'lucide-react' +import { formatClock, formatEventDate, formatMoney } from '@/lib/event-types' + +interface Entry { + registration: string + registration_status: string + payment_status: string + amount_due: number + amount_paid: number + currency: string + can_pay: boolean + next_action?: string + runner: { first_name?: string; full_name?: string } + edition: { edition_name?: string; event_date?: string | null; venue?: string; county?: string } + race: { category_name?: string; distance_label?: string; start_time?: string | null } + bib_number: string | null + race_pack_status: string + check_in_status: string + result: { finish_status: string; chip_time: number | null; gun_time: number | null; overall_rank?: number } | null + payments: { date: string; method: string | null; amount: number; status: string; reference: string }[] +} + +const STORE = 'lukenya-my-race-token' + +function duration(seconds: number | null) { + if (!seconds) return null + const s = Math.round(seconds) + return `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` +} + +async function post(url: string, body: unknown): Promise { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.') + return data as T +} + +export function MyRaceView() { + const [token, setToken] = useState(null) + const [entry, setEntry] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [email, setEmail] = useState('') + const [sent, setSent] = useState(null) + const [paying, setPaying] = useState(false) + + useEffect(() => { + // Take the token out of the address bar so it isn't shared by accident, and keep it for this tab. + const url = new URL(window.location.href) + const fromUrl = url.searchParams.get('token') + if (fromUrl) { + try { sessionStorage.setItem(STORE, fromUrl) } catch { /* private mode */ } + url.searchParams.delete('token') + window.history.replaceState(null, '', url.pathname + url.search) + } + let saved: string | null = fromUrl + if (!saved) { + try { saved = sessionStorage.getItem(STORE) } catch { saved = null } + } + setToken(saved) + if (!saved) { + setLoading(false) + return + } + post('/api/my-race', { token: saved }) + .then(setEntry) + .catch((err) => { + setError((err as Error).message) + try { sessionStorage.removeItem(STORE) } catch { /* ignore */ } + }) + .finally(() => setLoading(false)) + }, []) + + const requestLink = async () => { + setError(null) + try { + const res = await post<{ message: string }>('/api/my-race', { email }) + setSent(res.message) + } catch (err) { + setError((err as Error).message) + } + } + + const pay = async () => { + if (!entry || !token) return + setPaying(true) + setError(null) + try { + const res = await post<{ authorization_url: string }>('/api/payment/start', { + registration: entry.registration, + payment_token: token, + channel: 'mpesa', + }) + window.location.assign(res.authorization_url) + } catch (err) { + setError((err as Error).message) + setPaying(false) + } + } + + const card = 'max-w-[560px] mx-auto bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] p-6 md:p-10' + + if (loading) { + return ( +
+ +

Loading your entry…

+
+ ) + } + + if (!entry) { + return ( +
+

My Race

+

+ Enter the email you registered with and we'll send you a private link to your entry. +

+ {error &&

{error}

} + {sent ? ( +

{sent}

+ ) : ( +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="flex-1 px-4 h-12 rounded-lg border border-[#D1D5DB] font-sans text-[15px] focus:border-[#006838] focus:ring-2 focus:ring-[rgba(0,104,56,0.12)] outline-none" + /> + +
+ )} +
+ ) + } + + const confirmed = entry.registration_status === 'Confirmed' || entry.registration_status === 'Completed' + const balance = Math.max(entry.amount_due - entry.amount_paid, 0) + const rows: [string, string | null | undefined][] = [ + ['Race', [entry.race.distance_label, entry.race.category_name].filter(Boolean).join(' ')], + ['Race day', [formatEventDate(entry.edition.event_date), formatClock(entry.race.start_time) && `start ${formatClock(entry.race.start_time)}`].filter(Boolean).join(', ')], + ['Venue', [entry.edition.venue, entry.edition.county].filter(Boolean).join(', ')], + ['Entry', entry.registration], + ['Bib', entry.bib_number || 'Not allocated yet'], + ['Race pack', entry.race_pack_status], + ['Check-in', entry.check_in_status], + ] + + return ( +
+

{entry.edition.edition_name}

+

+ {entry.runner.first_name ? `${entry.runner.first_name}, ` : ''} + {confirmed ? "you're in." : 'your entry is not confirmed yet.'} +

+

+ {confirmed + ? `Paid ${formatMoney(entry.amount_paid, entry.currency) || ''}.` + : balance > 0 + ? `${formatMoney(balance, entry.currency)} is still to pay. Your place is confirmed once payment reaches us.` + : `Status: ${entry.registration_status}.`} +

+ + {entry.next_action && ( +

+ Next: {entry.next_action} +

+ )} + + {entry.can_pay && ( + + )} + {error &&

{error}

} + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value || '—'}
+
+ ))} + {entry.result && ( +
+
Result
+
+ {entry.result.finish_status} + {duration(entry.result.chip_time || entry.result.gun_time) ? `, ${duration(entry.result.chip_time || entry.result.gun_time)}` : ''} + {entry.result.overall_rank ? `, position ${entry.result.overall_rank}` : ''} +
+
+ )} +
+ + {entry.payments.length > 0 && ( +
+

Payments

+
    + {entry.payments.map((p) => ( +
  • + {formatEventDate(p.date)} · {p.method || 'Online'} + {formatMoney(p.amount, entry.currency)} · {p.status} +
  • + ))} +
+
+ )} + +

+ This page is private to you. Don't share the link from your email.{' '} + Race information +

+
+ ) +} diff --git a/app/my-race/page.tsx b/app/my-race/page.tsx new file mode 100644 index 0000000..a01b701 --- /dev/null +++ b/app/my-race/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { MyRaceView } from './my-race-view' + +export const metadata: Metadata = { + title: 'My Race | 10 Million Trees Marathon', + robots: { index: false }, + referrer: 'no-referrer', +} + +export default function MyRacePage() { + return ( +
+ +
+ +
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..bdbf50b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,632 @@ +import Link from 'next/link' +import Image from 'next/image' +import { + ShieldCheck, + Trophy, + TreePine, + Users, + MapPin, + Globe, + Bike, + ArrowRight +} from 'lucide-react' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { HeroCarousel } from '@/components/hero-carousel' +import { getPublicEvent, getPublicPrizes } from '@/lib/lukenya-api' +import { + countWord, + displayPrice, + editionLabel, + findPrize, + formatClock, + formatEventDate, + formatMoney, + registrationIsOpen, + registrationStatusText, + venueLine, +} from '@/lib/event-types' + +export const revalidate = 60 + +const galleryImages = [ + 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-4-100_result.jpg', + 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-5-100_result.jpg', + 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-6-100_result.jpg', + 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-7-100_result.jpg', + 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-8-100_result.jpg', +] + +const sponsors = [ + { name: 'Betika', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Betika.jpg' }, + { name: 'Greenlife', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Greenlife.jpg' }, + { name: 'Family Bank', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Family-bank.jpg' }, + { name: 'Lukenya Schools', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Lukenya-schools-1.jpg' }, +] + +const testimonials = [ + { + quote: "The Lukenya Hills will humble you. The finish line will change you. I have run 11 marathons. This one meant something different.", + name: "David M.", + category: "42KM Full Marathon", + edition: "4th Edition" + }, + { + quote: "I came for the race. I stayed because of the trees. Knowing my entry funded real reforestation made this the most purposeful race I have ever entered.", + name: "Amina K.", + category: "21KM Half Marathon", + edition: "3rd Edition" + } +] + +export default async function HomePage() { + const [event, prizes] = await Promise.all([getPublicEvent(), getPublicPrizes()]) + const edition = event.edition + const categories = event.categories + const isOpen = registrationIsOpen(event) + const label = editionLabel(edition) + const date = formatEventDate(edition?.event_date) + const venue = venueLine(edition) + const featured = categories.find((c) => c.is_featured) || categories[0] + const featuredStart = featured ? formatClock(featured.start_time) : null + + const heroStats = [ + { value: '300+', label: 'Runners' }, + { value: label || 'Next', label: isOpen ? 'Edition: entries open' : 'Edition' }, + { value: categories.length ? String(categories.length) : '–', label: 'Categories' }, + { value: '10M', label: 'Trees Mission' }, + ] + + const trustSignals = [ + { icon: ShieldCheck, label: 'Sporthive Certified Timing' }, + { icon: Trophy, label: 'Competitive Prize Money' }, + { icon: TreePine, label: '10M Trees Mission' }, + { icon: Users, label: '300+ Annual Finishers' }, + { icon: MapPin, label: venue || 'Venue to be announced' }, + { icon: Globe, label: 'Open to International Runners' }, + ] + + const featuredPrizeCategory = featured?.prize_eligible ? featured.name : undefined + const prizeCards = [ + { icon: Trophy, title: `Men, ${featured?.distance_label || 'Marathon'}, 1st place`, amount: findPrize(prizes, 'Men', 1, featuredPrizeCategory) }, + { icon: Trophy, title: `Women, ${featured?.distance_label || 'Marathon'}, 1st place`, amount: findPrize(prizes, 'Women', 1, featuredPrizeCategory) }, + { icon: Users, title: 'Top corporate relay team', amount: findPrize(prizes, 'Corporate Relay', 1) }, + ] + + return ( +
+ + + {/* SECTION 1 — HERO (FIX 1: Left-aligned layout) */} +
+ + + {/* Hero Content - LEFT ALIGNED */} +
+
+
+ {/* Eyebrow Badge */} + + {['Kenya', edition?.county, label].filter(Boolean).join(' · ')} + + + {/* H1 */} +

+ Run, Ride
& Restore. +

+ + {/* Subheading */} +

+ Africa's only university-backed marathon with a verified reforestation mission.
+ {[categories.length ? `${categories.length} categories` : null, venue, date].filter(Boolean).join(' · ')} +

+ + {/* CTA Buttons */} +
+ + Register Now + + + Race Information + +
+ + {/* FIX 2: Registration Status Badge (replaces countdown) */} +
+ + + {registrationStatusText(event)} + +
+
+
+
+ + {/* Stat Strip (FIX 3: Edition contradiction fixed) */} +
+
+
+ {heroStats.map((stat, index) => ( +
+

+ {stat.value} +

+

+ {stat.label} +

+
+ ))} +
+
+
+
+ + {/* SECTION 2 — TRUST BAR (FIX 6: Icons restored, location canonicalized) */} +
+
+
+ {trustSignals.map((signal, index) => ( +
+ + + {signal.label} + +
+ ))} +
+
+
+ + {/* FIX 8: NEW RACE DATE ANCHOR SECTION */} +
+
+
+ {/* Column 1 — WHEN */} +
+ + Race Day + +
+

+ {date ? date.toUpperCase() : 'DATE TO BE ANNOUNCED'} +

+

+ {label ? `${label} · ` : ''}Annual event +

+
+ + {/* Column 2 — WHERE */} +
+ + Location + +
+

+ {(edition?.venue || 'To be announced').toUpperCase()} +

+

+ {[edition?.county ? `${edition.county}, Kenya` : 'Kenya', featured && featuredStart ? `${featured.distance_label} start: ${featuredStart}` : null].filter(Boolean).join(' · ')} +

+
+ + {/* Column 3 — START TIMES (from the race categories) */} +
+ + Start Times + +
+

+ {featuredStart || 'TBA'} +

+

+ {categories.filter((c) => c.start_time).slice(0, 4).map((c) => `${c.distance_label} ${formatClock(c.start_time)}`).join(' · ') || 'Start times are published with the race categories.'} +

+
+
+
+
+ + {/* SECTION 3 — ENVIRONMENTAL MISSION (FIX 7: Clarified 10,000 vs 10M) */} +
+
+
+ {/* Left Column */} +
+ {/* Eyebrow */} + + Our Mission + + {/* Gold Rule */} +
+ + {/* H2 */} +

+ More Than a Finish Line. +

+ + {/* Body (FIX 7: Updated copy) */} +

+ The 10 Million Trees Marathon exists because of a promise made to Kenya's dryland ecosystems. Every runner, every kilometre, every edition is a contribution to one of East Africa's most ambitious conservation programmes. By 2027, the programme will have planted 10 million trees across the ASAL region — a goal now in its 5th year of active delivery. +

+ + {/* Stat Pills */} +
+
+ 10M + Trees by 2027 +
+
+ 4 + Editions Completed +
+
+ ASAL + Region Impact +
+
+ + {/* CTA */} + + Our Conservation Story + +
+ + {/* Right Column (FIX 7: Updated labels) */} +
+

+ 10,000+ +

+

+ Trees planted and GPS-tagged to date +

+

+ Goal: 10 Million by 2027 · 4 editions completed · On track +

+

+ GPS-tagged planting zones in Lukenya Conservation Area. Documented, verified, reportable. +

+ + Verified Programme + +
+
+
+
+ + {/* SECTION 4 — RACE CATEGORIES (FIX 4: Removed TBD pricing, added fallback) */} +
+
+ {/* Header */} +
+ + Race Categories + +
+

+ Find Your Distance. +

+

+ {categories.length ? `${countWord(categories.length)} ${categories.length === 1 ? 'category' : 'categories'}. ` : ''}Every level of runner and rider. One extraordinary finish line. +

+
+ + {/* Category Grid */} +
+ {categories.length === 0 && ( +

+ Race categories for the next edition will be published here soon. +

+ )} + {categories.map((category) => { + const price = formatMoney(displayPrice(category, 'resident'), event.currency) + const isCycling = category.discipline === 'Cycling' + return ( +
+ {/* Prize Badge */} + {category.prize_eligible && ( + + Prize Money + + )} + + {/* Cycling Icon */} + {isCycling && !category.prize_eligible && ( + + )} + + {/* Distance */} +

+ {category.distance_label} +

+ + {/* Name */} +

+ {category.category_name} +

+ + {/* Description */} +

+ {category.short_description || category.eligibility || category.inclusions.join(' · ')} +

+ + {/* Featured race: inline stat */} + {category.is_featured && category.prize_eligible && ( +

+ 🏆 Prize money available · Certified course · Chip timing +

+ )} + + {/* Price */} +
+ {category.sold_out ? ( + + Sold out + + ) : price ? ( + <> +

{price}

+

+ {category.prices.early_bird_active ? 'Early-bird rate for Kenyan residents' : 'Kenyan resident rate'} +

+ + ) : ( + + Pricing announced soon + + )} +
+ + {/* CTA */} + + {isOpen && !category.sold_out ? 'Enter this race' : 'Join waitlist'} + +
+ ) + })} +
+ + {/* CTAs */} +
+ + View All Race Info + + + Register Now + +
+
+
+ + {/* SECTION 5 — PRIZE MONEY TEASER (FIX 5: Replaced TBD with competitive framing) */} +
+
+ {/* Header */} +
+ + Prize Money + +
+

+ Compete for Real Rewards. +

+
+ + {/* Prize Cards: amounts come from the edition's prize rules */} +
+ {prizeCards.map((card) => ( +
+ +

+ {card.title} +

+

+ {formatMoney(card.amount, prizes?.currency || event.currency) || 'PRIZE MONEY AVAILABLE'} +

+ {!card.amount && ( +

+ Amounts published before entries open +

+ )} +
+ ))} +
+ + {/* Prize structure note (FIX 5) */} +

+ Prize structure includes Men, Women, Wheelchair, and Corporate Relay categories. Full breakdown published on the Prize Money page. +

+ + {/* CTA */} +
+ + View Full Prize Structure + +
+
+
+ + {/* SECTION 6 — PAST EDITIONS / SOCIAL PROOF (FIX 3: Updated headline) */} +
+
+ {/* Header */} +
+ + Past Editions + +
+

+ 300+ Runners. Across 4 Editions. One Growing Legacy. +

+
+ + {/* Photo Strip */} +
+ {galleryImages.map((src, index) => ( +
+ {`Marathon +
+
+ ))} +
+ + {/* Testimonials (ADDITIONAL FIX 2) */} +
+ {testimonials.map((testimonial, index) => ( +
+ + “ + +

+ {testimonial.quote} +

+

+ — {testimonial.name} +

+

+ {testimonial.category} · {testimonial.edition} +

+
+ ))} +
+ + {/* Stat */} +
+

+ 300+ +

+

+ runners have crossed this finish line +

+
+ + {/* CTA */} +
+ + View Results Archive + +
+
+
+ + {/* SECTION 7 — SPONSORS */} +
+
+ {/* Header */} + + Our Sponsors + +
+

+ Brands That Run With Purpose. +

+ + {/* Sponsor Logos */} +
+ {sponsors.map((sponsor, index) => ( +
+ {sponsor.name} +
+ ))} +
+ +

+ Including our Environmental Partner tier — the only ESG-verified sponsorship in Kenyan running. +

+ + {/* CTA */} + + Become a Partner + +
+
+ + {/* SECTION 8 — REGISTRATION CTA BAND */} +
+
+

+ {edition?.trees_commitment_per_entry ? 'Your Entry Plants a Tree.' : 'Run With Purpose.'} +

+

+ {isOpen ? `Register for the ${label || 'next edition'}. Run for Kenya. Entries open now.` : `Entries for the ${label || 'next edition'} open soon. Join the waitlist to hear first.`} +

+ + Register Now + +

+ {featured?.prices.early_bird_active ? 'Early-bird pricing available. Secure your place before rates increase.' : 'Pay by M-PESA or card.'} +

+
+
+ +
+
+ ) +} diff --git a/app/payment/return/page.tsx b/app/payment/return/page.tsx new file mode 100644 index 0000000..a47c24f --- /dev/null +++ b/app/payment/return/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { PaymentStatus } from './payment-status' + +export const metadata: Metadata = { + title: 'Payment | 10 Million Trees Marathon', + robots: { index: false }, +} + +export default function PaymentReturnPage() { + return ( +
+ +
+ +
+
+
+ ) +} diff --git a/app/payment/return/payment-status.tsx b/app/payment/return/payment-status.tsx new file mode 100644 index 0000000..881f072 --- /dev/null +++ b/app/payment/return/payment-status.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { CheckCircle2, Loader2, XCircle, Clock } from 'lucide-react' +import { formatMoney } from '@/lib/event-types' + +interface StatusResponse { + status?: string + registration?: string + registration_status?: string + amount?: number + currency?: string + error?: string +} + +type View = 'checking' | 'paid' | 'failed' | 'waiting' | 'error' + +const POLL_MS = 3000 +const MAX_POLLS = 20 // about a minute; M-PESA confirmations usually land well inside this + +export function PaymentStatus() { + const [view, setView] = useState('checking') + const [data, setData] = useState({}) + const [reference, setReference] = useState(null) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + // Paystack sends the runner back with ?reference=... (and ?trxref=... as a copy) + const ref = params.get('reference') || params.get('trxref') + setReference(ref) + if (!ref) { + setView('error') + setData({ error: 'This page needs a payment reference. Open it from the payment page, or check your email.' }) + return + } + + let polls = 0 + let timer: ReturnType + let stopped = false + + const check = async () => { + polls += 1 + try { + const res = await fetch(`/api/payment/status?reference=${encodeURIComponent(ref)}`, { cache: 'no-store' }) + const body = (await res.json()) as StatusResponse + if (stopped) return + if (!res.ok) throw new Error(body.error || 'Could not check the payment.') + setData(body) + const status = (body.status || '').toLowerCase() + if (status === 'paid' || status === 'reconciled') return setView('paid') + if (status === 'failed' || status === 'abandoned') return setView('failed') + if (polls >= MAX_POLLS) return setView('waiting') + setView('checking') + } catch (err) { + if (stopped) return + if (polls >= MAX_POLLS) { + setData({ error: (err as Error).message }) + return setView('error') + } + } + timer = setTimeout(check, POLL_MS) + } + check() + return () => { + stopped = true + clearTimeout(timer) + } + }, []) + + const amount = formatMoney(data.amount ?? null, data.currency || 'KES') + + return ( +
+ {view === 'checking' && ( + <> + +

Confirming your payment

+

+ We're checking with the payment provider. If you paid by M-PESA, this can take up to a minute. Keep this page open. +

+ + )} + + {view === 'paid' && ( + <> + +

You're in.

+

+ Payment received{amount ? ` (${amount})` : ''}. Your entry{data.registration ? ` ${data.registration}` : ''} is confirmed. +

+

+ A confirmation email is on its way with your private My Race link, where you can see your bib and race pack status. +

+ + Read the race information + + + )} + + {view === 'failed' && ( + <> + +

Payment not completed

+

+ No money was taken for this attempt. Your details are saved{data.registration ? ` under entry ${data.registration}` : ''}, so you can try again without filling the form. +

+ + Try again + + + )} + + {view === 'waiting' && ( + <> + +

Still waiting for confirmation

+

+ The payment provider hasn't confirmed your payment yet. If money left your account, don't pay again: your entry is confirmed automatically when the confirmation arrives, and we'll email you. +

+ + + )} + + {view === 'error' && ( + <> + +

We couldn't check this payment

+

{data.error}

+ {reference &&

Payment reference: {reference}

} + + Email us with your payment reference + + + )} +
+ ) +} diff --git a/app/prize-money/page.tsx b/app/prize-money/page.tsx new file mode 100644 index 0000000..6c94753 --- /dev/null +++ b/app/prize-money/page.tsx @@ -0,0 +1,258 @@ +import Link from 'next/link' +import { Trophy, ShieldCheck, Accessibility, Users, Award } from 'lucide-react' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { getPublicEvent, getPublicPrizes } from '@/lib/lukenya-api' +import { findPrize, formatMoney, ordinal, type PrizeCategory, type PrizeRow } from '@/lib/event-types' + +export const revalidate = 60 + +type PrizeLine = { place: string; amount: string; highlight?: boolean } + +const PLACE = (n: number) => ordinal(n) || String(n) + +// Men and women side by side, one row per finishing position. +function rowsFor(category: PrizeCategory, currency: string): { men: PrizeLine[]; women: PrizeLine[] } { + const men = category.divisions.find((d) => d.division === 'Men')?.places || [] + const women = category.divisions.find((d) => d.division === 'Women')?.places || [] + const positions = Array.from(new Set([...men, ...women].map((p) => p.position))).sort((x, y) => x - y) + const fmt = (rows: PrizeRow[], pos: number) => { + const row = rows.find((r) => r.position === pos) + return row ? (formatMoney(row.amount, currency)?.replace(`${currency} `, '') || '—') : '—' + } + return { + men: positions.map((pos) => ({ place: PLACE(pos), amount: fmt(men, pos), highlight: pos === 1 })), + women: positions.map((pos) => ({ place: PLACE(pos), amount: fmt(women, pos), highlight: pos === 1 })), + } +} + +const SPECIAL = [ + { division: 'Wheelchair', icon: Accessibility, title: 'Wheelchair Category', description: 'Top finishers in the wheelchair division' }, + { division: 'Corporate Relay', icon: Users, title: 'Corporate Relay', description: 'Fastest corporate relay teams' }, + { division: 'Age Group', icon: Award, title: 'Age Category Awards', description: 'Top finishers in each age group' }, +] + +function PrizeTable({ + title, + men, + women +}: { + title: string + men: { place: string; amount: string; highlight?: boolean }[] + women: { place: string; amount: string; highlight?: boolean }[] +}) { + return ( +
+
+

{title}

+
+
+ + + + + + + + + + {men.map((prize, index) => ( + + + + + + ))} + +
PlaceMenWomen
+ {prize.place} + + {prize.amount} + + {women[index]?.amount} +
+
+
+ ) +} + +function CompactPrizeTable({ + title, + men, + women +}: { + title: string + men: { place: string; amount: string }[] + women: { place: string; amount: string }[] +}) { + return ( +
+
+

{title}

+
+
+ + + + + + + + + + {men.map((prize, index) => ( + + + + + + ))} + +
PlaceMenWomen
{prize.place}{prize.amount}{women[index]?.amount}
+
+
+ ) +} + +export default async function PrizeMoneyPage() { + const [event, prizes] = await Promise.all([getPublicEvent(), getPublicPrizes()]) + const currency = prizes?.currency || event.currency + // Races with Men/Women prize rules, in the order the backend sends them (featured first). + const raceTables = (prizes?.categories || []).filter((c) => + c.divisions.some((d) => d.division === 'Men' || d.division === 'Women'), + ) + const [mainRace, ...otherRaces] = raceTables + const specials = SPECIAL.map((s) => ({ ...s, amount: findPrize(prizes, s.division, 1) })) + return ( +
+ + + {/* Page Hero */} +
+
+ + Compete for Real Rewards + +

+ Prize Money +

+

+ {raceTables.length ? `Structured prize money across ${raceTables.length} ${raceTables.length === 1 ? 'race' : 'races'}, paid on race day.` : 'Structured prize money, paid on race day.'} +

+
+
+ + {/* Main race */} +
+
+
+ + + Top Prize Category + +

+ {mainRace ? `${mainRace.category_name}, ${mainRace.distance_label}` : 'Prize structure'} +

+
+ + {mainRace ? ( + + ) : ( +

+ Prize amounts for each race are published here before entries open. +

+ )} +
+
+ + {otherRaces.length > 0 && ( +
+
+
+

Other races

+
+
+ {otherRaces.map((race) => ( + + ))} +
+
+
+ )} + + {/* Special Categories */} +
+
+
+ + Special Categories + +

+ Additional Prize Categories +

+
+ +
+ {specials.map((category, index) => ( +
+ +

{category.title}

+

{formatMoney(category.amount, currency) || 'To be announced'}

+

{category.description}

+
+ ))} +
+
+
+ + {/* Prize Payment Info */} +
+
+
+
+ + Sporthive Certified Results +
+ | +

+ Prize money paid on race day. Winners must present valid ID. All prizes subject to race rules. +

+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to Compete? +

+

+ Register now and compete for prize money at Kenya's environmental marathon. +

+ + REGISTER NOW + +
+
+ +
+
+ ) +} diff --git a/app/race-info/page.tsx b/app/race-info/page.tsx new file mode 100644 index 0000000..6eac5a5 --- /dev/null +++ b/app/race-info/page.tsx @@ -0,0 +1,279 @@ +import Link from 'next/link' +import { Clock, MapPin, Users, ChevronDown, Route, Droplets, Award, ShieldCheck } from 'lucide-react' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { getPublicEvent } from '@/lib/lukenya-api' +import { + displayPrice, + formatClock, + formatCutoff, + formatMoney, + type PublicCategory, +} from '@/lib/event-types' + +export const revalidate = 60 + +function startTimesAnswer(categories: PublicCategory[]): string { + const timed = categories.filter((c) => c.start_time) + if (!timed.length) return 'Start times are published with the race categories closer to race day.' + return timed.map((c) => `${c.category_name} (${c.distance_label}): ${formatClock(c.start_time)}`).join('. ') + '.' +} + +function ageAnswer(categories: PublicCategory[]): string { + const parts = categories.map((c) => { + if (c.min_age) return `${c.category_name}: ${c.min_age}+` + if (c.eligibility) return `${c.category_name}: ${c.eligibility}` + return null + }).filter(Boolean) + if (!parts.length) return 'Age limits are listed with each race category. Runners under 18 need a parent or guardian to consent.' + return parts.join('. ') + '. Runners under 18 need a parent or guardian to consent.' +} + +const routeHighlights = [ + { icon: Route, title: 'Course Type', description: 'Mixed terrain: tarmac and scenic trails' }, + { icon: Droplets, title: 'Water Stations', description: 'Every 2.5km with hydration and first aid' }, + { icon: Award, title: 'Timing', description: 'Sporthive certified chip timing' }, +] + +const staticFaqs = [ + { + question: 'Is parking available?', + answer: 'Yes, free parking is available at the venue. Follow the signage to the designated parking areas. Arrive early for the best spots.' + }, + { + question: 'Where do I collect my race pack?', + answer: 'Race packs can be collected at the Expo on the day before the race (10 AM - 6 PM) or on race morning from 4:30 AM at the registration tent.' + }, + { + question: 'Is bag drop available?', + answer: 'Yes, secure bag drop is available near the start line. Please label your bag with your bib number. Bags can be collected after you finish.' + }, + { + question: 'What is included in my entry fee?', + answer: 'Race bib with chip timing, finisher medal, event t-shirt, access to water stations and post-race refreshments. Inclusions for each race are listed on the registration page.' + }, + { + question: 'Can I transfer my entry to someone else?', + answer: 'Entry transfers are allowed up to 7 days before the race for a small admin fee. Contact info@lukenyamarathon.co.ke to arrange a transfer.' + }, + { + question: 'What happens if it rains?', + answer: 'The race proceeds rain or shine. Kenya\'s climate is generally favorable, but come prepared with appropriate gear.' + }, +] + +export default async function RaceInfoPage() { + const event = await getPublicEvent() + const categories = event.categories + const edition = event.edition + const featured = categories.find((c) => c.is_featured) || categories[0] + const featuredStart = featured ? formatClock(featured.start_time) : null + const routeItems = [ + { icon: MapPin, title: 'Start/Finish', description: edition?.venue || 'Venue to be announced' }, + ...routeHighlights, + ] + const faqs = [ + { question: 'What time does the race start?', answer: startTimesAnswer(categories) }, + staticFaqs[0], + staticFaqs[1], + staticFaqs[2], + { question: 'Are there age requirements?', answer: ageAnswer(categories) }, + ...staticFaqs.slice(3), + ] + return ( +
+ + + {/* Page Hero */} +
+
+ + Everything You Need to Know + +

+ Race Information +

+

+ Categories, route details, timing, and everything you need for race day{edition?.venue ? ` at ${edition.venue}` : ''}. +

+
+
+ + {/* Quick Info Bar */} +
+
+
+
+ +

{featuredStart ? `${featuredStart} Start` : 'Start time TBA'}

+

{featured?.category_name || 'First race'}

+
+
+ +

{edition?.venue || 'Venue TBA'}

+

{edition?.county || 'Kenya'}

+
+
+ +

{categories.length || 'All'} Categories

+

All abilities

+
+
+ +

Sporthive Timing

+

Certified results

+
+
+
+
+ + {/* Race Categories */} +
+
+
+ + Race Categories + +

+ Choose Your Challenge +

+
+ +
+ + + + + + + + + + + + {categories.length === 0 && ( + + + + )} + {categories.map((category) => ( + + + + + + + + ))} + +
CategoryDistanceCut-off TimeElevationEntry Fee (Kenyan resident)
+ Race categories for the next edition will be published here soon. +
+ {category.category_name} + {category.sold_out && Sold out} + + {category.distance_km ? `${category.distance_km}km` : category.distance_label} + {formatCutoff(category.cutoff_hours) || '—'}{category.elevation_gain_m ? `+${category.elevation_gain_m}m` : '—'} + {formatMoney(displayPrice(category, 'resident'), event.currency) || 'Announced soon'} +
+
+ +
+ + REGISTER NOW + +
+
+
+ + {/* Route Information */} +
+
+
+ + The Course + +

+ Route Information +

+
+ + {/* Route Map Placeholder */} +
+
+
+ +

Course Map Coming Soon

+
+
+
+ + {/* Route Highlights */} +
+ {routeItems.map((item, index) => ( +
+ +

{item.title}

+

{item.description}

+
+ ))} +
+
+
+ + {/* FAQ Section */} +
+
+
+ + Common Questions + +

+ Frequently Asked Questions +

+
+ +
+ {faqs.map((faq, index) => ( +
+ + {faq.question} + + +
+ {faq.answer} +
+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to Run? +

+

+ Secure your place in Kenya's only environmental marathon. +

+ + REGISTER NOW + +
+
+ +
+
+ ) +} diff --git a/app/registration/page.tsx b/app/registration/page.tsx new file mode 100644 index 0000000..f24b965 --- /dev/null +++ b/app/registration/page.tsx @@ -0,0 +1,1358 @@ +'use client' + +import { useState, useEffect, useRef, useMemo } from 'react' +import Link from 'next/link' +import { Navigation } from '@/components/navigation' +import { Footer } from '@/components/footer' +import { Check, TreePine, Globe, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' +import { useEvent } from '@/components/event-provider' +import { + displayPrice, + editionLabel, + formatClock, + formatDateTime, + formatEventDate, + formatMoney, + venueLine, + type PublicCategory, + type Residency, +} from '@/lib/event-types' + +interface LegalDoc { + name: string + document_type: string + slug: string + version: string + title: string + summary: string | null + body: string + applies_to: string + required: boolean +} + +// Used only if the backend can't be reached; the server still checks the current versions. +const FALLBACK_INDEMNITY: LegalDoc = { + name: '', document_type: 'Indemnity', slug: 'indemnity', version: '1', title: 'Indemnity Declaration', summary: null, + applies_to: 'All runners', required: true, + body: indemnityTextFallback(), +} + +function indemnityTextFallback() { + return '

I, the undersigned, hereby declare that I am physically fit and have trained sufficiently to participate in the Lukenya University Marathon. I understand that participating in a marathon involves inherent risks including, but not limited to, physical exertion, weather conditions, and contact with other participants. I voluntarily assume all risks associated with my participation in this event.

I release and hold harmless Lukenya University, the event organizers, sponsors, volunteers, and all affiliated parties from any and all claims, damages, or liabilities that may arise from my participation in this event. I consent to receive medical treatment that may be deemed necessary in the event of injury or illness during the race.

I confirm that all information provided in this registration is accurate and complete.

' +} + +// Turns the stored document HTML into plain paragraphs for the scroll box. +function paragraphs(html: string): string[] { + return (html || '') + .replace(/<\/(p|div|li)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .split('\n') + .map((x) => x.trim()) + .filter(Boolean) +} + +const RELATIONSHIPS = ['Parent', 'Spouse or partner', 'Sibling', 'Child', 'Other relative', 'Friend', 'Colleague', 'Other'] +const STORE_KEY = 'lm-registration-draft' + +interface RaceOption { + id: string + distance: string + name: string + description: string + inclusions: string[] + hasPrizeMoney: boolean + isVirtual: boolean + soldOut: boolean + closed: boolean + category: PublicCategory +} + +function toRaceOption(c: PublicCategory): RaceOption { + const start = formatClock(c.start_time) + return { + id: c.name, + distance: c.distance_label, + name: c.category_name, + description: c.short_description || (c.distance_km ? `${c.distance_km} kilometres${start ? `, starts ${start}` : ''}` : c.discipline), + inclusions: c.inclusions, + hasPrizeMoney: c.prize_eligible, + isVirtual: c.discipline === 'Virtual', + soldOut: c.sold_out || Boolean(c.closed), + closed: Boolean(c.closed), + category: c, + } +} + +function ageOn(dob: string, onDate: string | null | undefined): number | null { + if (!dob) return null + const birth = new Date(`${dob}T00:00:00`) + const ref = onDate ? new Date(`${onDate.slice(0, 10)}T00:00:00`) : new Date() + if (Number.isNaN(birth.getTime()) || Number.isNaN(ref.getTime())) return null + let age = ref.getFullYear() - birth.getFullYear() + const m = ref.getMonth() - birth.getMonth() + if (m < 0 || (m === 0 && ref.getDate() < birth.getDate())) age-- + return age +} + +async function postJSON(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.') + return data as T +} + +const tshirtSizes = ['XS', 'S', 'M', 'L', 'XL', 'XXL'] + + +export default function RegistrationPage() { + const { event, registrationEnabled } = useEvent() + const edition = event.edition + const races = useMemo(() => event.categories.map(toRaceOption), [event.categories]) + const isOpen = edition?.registration_state === 'open' + const canPay = registrationEnabled && isOpen + const label = editionLabel(edition) + const eventDate = formatEventDate(edition?.event_date) + const venue = venueLine(edition) + const trees = edition?.trees_commitment_per_entry || 0 + const treesText = trees > 0 ? `${trees} ${trees === 1 ? 'tree' : 'trees'} planted in your name` : null + + const [currentStep, setCurrentStep] = useState(1) + const [selectedRace, setSelectedRace] = useState(null) + const [entryCategory, setEntryCategory] = useState('resident') + const [paymentMethod, setPaymentMethod] = useState<'mpesa' | 'card' | null>(null) + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + // Set once the entry exists, so a failed payment start can be retried without re-registering. + const [savedEntry, setSavedEntry] = useState<{ registration: string; amount_due: number; currency: string; payment_token?: string } | null>(null) + const [legalDocs, setLegalDocs] = useState([FALLBACK_INDEMNITY]) + const [consents, setConsents] = useState>({}) + const [mobileSummaryOpen, setMobileSummaryOpen] = useState(false) + const [errors, setErrors] = useState>({}) + + const formRef = useRef(null) + + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + idNumber: '', + dob: '', + gender: '', + nationality: 'Kenya', + email: '', + phone: '', + whatsapp: '', + tshirtSize: '', + emergencyName: '', + emergencyPhone: '', + emergencyRelationship: '', + supportRequest: '', + guardianName: '', + guardianPhone: '', + }) + + const runnerAge = ageOn(formData.dob, edition?.event_date) + const isMinor = runnerAge !== null && runnerAge < 18 + + useEffect(() => { + fetch('/api/legal') + .then((r) => (r.ok ? r.json() : null)) + .then((docs: LegalDoc[] | null) => { + if (Array.isArray(docs) && docs.length) setLegalDocs(docs) + }) + .catch(() => { /* keep the built-in indemnity */ }) + }, []) + + // Keep what the runner typed if they navigate away and come back in this tab. + useEffect(() => { + try { + const saved = JSON.parse(window.sessionStorage.getItem(STORE_KEY) || 'null') + if (saved?.formData) setFormData((prev) => ({ ...prev, ...saved.formData })) + if (saved?.entryCategory) setEntryCategory(saved.entryCategory) + if (saved?.selectedRace) setSelectedRace(saved.selectedRace) + } catch { /* storage unavailable */ } + }, []) + useEffect(() => { + try { + window.sessionStorage.setItem(STORE_KEY, JSON.stringify({ formData, entryCategory, selectedRace })) + } catch { /* storage unavailable */ } + }, [formData, entryCategory, selectedRace]) + + // Documents that apply to this runner: under-18-only ones appear when the date of birth says so. + const applicableDocs = legalDocs.filter((d) => d.applies_to !== 'Under 18 only' || isMinor) + const requiredDocs = applicableDocs.filter((d) => d.required) + const optionalDocs = applicableDocs.filter((d) => !d.required && d.name) + const indemnityDoc = legalDocs.find((d) => d.document_type === 'Indemnity') || FALLBACK_INDEMNITY + const docKey = (d: LegalDoc) => d.name || d.document_type + const allRequiredAccepted = requiredDocs.every((d) => consents[docKey(d)]) + + // Pre-select a race passed from another page, e.g. /registration?race=42KM + useEffect(() => { + const wanted = new URLSearchParams(window.location.search).get('race') + if (wanted && races.some((r) => r.id === wanted && !r.soldOut)) setSelectedRace(wanted) + }, [races]) + + const selectedRaceData = races.find(r => r.id === selectedRace) + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData(prev => ({ ...prev, [name]: value })) + // Clear error when user types + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: '' })) + } + } + + const scrollToForm = () => { + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + const handleRaceRowClick = (raceId: string) => { + setSelectedRace(raceId) + scrollToForm() + } + + const validateStep1 = () => { + return selectedRace !== null && !selectedRaceData?.soldOut + } + + const validateStep2 = () => { + const newErrors: Record = {} + + if (!formData.firstName.trim()) newErrors.firstName = 'First name is required' + if (!formData.lastName.trim()) newErrors.lastName = 'Last name is required' + if (!formData.idNumber.trim()) newErrors.idNumber = 'ID/Passport number is required' + if (!formData.dob) newErrors.dob = 'Date of birth is required' + if (!formData.gender) newErrors.gender = 'Gender is required' + if (!formData.nationality) newErrors.nationality = 'Nationality is required' + if (!formData.email.trim()) newErrors.email = 'Email address is required' + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) newErrors.email = 'Please enter a valid email' + if (!formData.phone.trim()) newErrors.phone = 'Phone number is required' + if (!formData.tshirtSize) newErrors.tshirtSize = 'T-shirt size is required' + if (!formData.emergencyName.trim()) newErrors.emergencyName = 'Emergency contact name is required' + if (!formData.emergencyPhone.trim()) newErrors.emergencyPhone = 'Emergency contact phone is required' + if (!formData.emergencyRelationship) newErrors.emergencyRelationship = 'Choose how this person is related to you' + const race = selectedRaceData?.category + if (race?.min_age && runnerAge !== null && runnerAge < race.min_age) { + newErrors.dob = `${race.category_name} is for runners aged ${race.min_age} and over on race day` + } + if (isMinor) { + if (!formData.guardianName.trim()) newErrors.guardianName = 'A parent or guardian must be named for runners under 18' + if (!formData.guardianPhone.trim()) newErrors.guardianPhone = 'Parent or guardian phone is required' + } + if (!allRequiredAccepted) newErrors.indemnity = 'Please tick each required box to continue' + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleContinueToStep2 = () => { + if (validateStep1()) { + setCurrentStep(2) + scrollToForm() + } + } + + const handleContinueToStep3 = () => { + if (validateStep2()) { + setCurrentStep(3) + setSubmitError(null) + scrollToForm() + } + } + + const startPayment = async (entry: NonNullable) => { + const pay = await postJSON<{ authorization_url: string }>('/api/payment/start', { + registration: entry.registration, + payment_token: entry.payment_token, + channel: paymentMethod, + }) + // Off to the secure payment page. The payment is confirmed by the server, not by this page. + window.location.assign(pay.authorization_url) + } + + const handleSubmit = async () => { + if (!paymentMethod || !selectedRaceData || !edition && event.source !== 'fallback') return + if (!canPay) { + setSubmitError('Online payment is not open yet. Your details have not been sent.') + return + } + setSubmitting(true) + setSubmitError(null) + try { + let entry = savedEntry + if (!entry) { + entry = await postJSON>('/api/register', { + event_edition: selectedRaceData.category && edition ? edition.name : undefined, + race_category: selectedRaceData.id, + residency: entryCategory === 'resident' ? 'Resident' : 'Non-resident', + first_name: formData.firstName.trim(), + last_name: formData.lastName.trim(), + id_number: formData.idNumber.trim(), + date_of_birth: formData.dob, + gender: formData.gender === 'male' ? 'Male' : formData.gender === 'female' ? 'Female' : formData.gender, + nationality: formData.nationality, + email: formData.email.trim(), + phone: formData.phone.trim(), + whatsapp: formData.whatsapp.trim(), + shirt_size: formData.tshirtSize, + emergency_contact_name: formData.emergencyName.trim(), + emergency_contact_phone: formData.emergencyPhone.trim(), + emergency_contact_relationship: formData.emergencyRelationship, + support_request: formData.supportRequest.trim() || undefined, + guardian_name: isMinor ? formData.guardianName.trim() : undefined, + guardian_phone: isMinor ? formData.guardianPhone.trim() : undefined, + indemnity_accepted: allRequiredAccepted, + consents: applicableDocs.filter((d) => d.name).length + ? applicableDocs.filter((d) => d.name).map((d) => ({ name: d.name, accepted: Boolean(consents[docKey(d)]) })) + : undefined, + preferred_payment: paymentMethod, + }) + setSavedEntry(entry) + } + await startPayment(entry) + } catch (err) { + setSubmitError((err as Error).message) + setSubmitting(false) + } + } + + // Price display component + const PriceDisplay = ({ race, residency = entryCategory, className = '' }: { race?: RaceOption; residency?: Residency; className?: string }) => { + const price = race ? formatMoney(displayPrice(race.category, residency), event.currency) : null + if (race?.soldOut) { + return ( + + {race.closed ? 'Entries closed' : 'Sold out'} + + ) + } + if (price) { + return {price} + } + return ( + + Coming Soon + + ) + } + + // Summary Panel Content + const SummaryContent = () => ( + <> + {!selectedRaceData ? ( +
+

+ Select your race above to see your entry summary. +

+
+ ) : ( + <> + {/* Selected Race */} +
+ + {selectedRaceData.distance} + +

+ {selectedRaceData.name} +

+

+ {entryCategory === 'resident' ? 'Kenyan resident' : 'Non-resident'} +

+
+ +
+

+ Entry Fee +

+ +

+ Includes: {selectedRaceData.inclusions.join(' · ')} +

+
+ +
+

+ What You Get +

+
    + {selectedRaceData.inclusions.map((item, i) => ( +
  • + + {item} +
  • + ))} + {treesText && ( +
  • + + {treesText} in ASAL Kenya +
  • + )} +
+
+ +
+
+
+ + + Your Environmental Contribution + +
+

+ {treesText ? `Your entry: ${treesText}. ` : ''}Trees are GPS-tagged and planted in the Lukenya Conservation Area, Machakos County. +

+
+
+ 10M Trees Goal +
+
+
+
+

10,000 planted so far

+
+
+
+ +
+

+ Questions about registration? +

+ + Email us + + + WhatsApp us + +
+ + )} + + ) + + return ( +
+ + + {/* SECTION 1: PAGE HERO */} +
+
+
+ {/* Left content */} +
+ + {(label || 'Next edition').toUpperCase()} + +
+

+ REGISTER FOR THE RACE +

+

+ {[eventDate, venue, isOpen ? 'Entries open now' : edition?.registration_state === 'closed' ? 'Entries closed' : 'Entries open soon'].filter(Boolean).join(' · ')} +

+
+ + {/* Right trust pills - desktop only */} +
+ {['Sporthive Certified Timing', 'M-PESA & Card Accepted', 'Instant Confirmation'].map((pill) => ( + + + {pill} + + ))} +
+
+
+
+ + {/* SECTION 2: PRICING TABLE */} +
+
+
+ + ENTRY FEES + +
+

+ Choose Your Race. +

+

+ Pricing shown in Kenyan Shillings ({event.currency}).{' '} + {races.some((r) => r.category.prices.early_bird_active) && formatDateTime(edition?.early_bird_ends) + ? `Early-bird rates apply until ${formatDateTime(edition?.early_bird_ends)}. ` + : ''} + Non-residents pay non-resident rates. +

+
+ + {/* Desktop Table */} +
+ + + + + + + + + + + + {races.map((race, index) => ( + handleRaceRowClick(race.id)} + className={`cursor-pointer transition-colors hover:bg-[rgba(0,104,56,0.04)] ${ + index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]' + } ${selectedRace === race.id ? 'bg-[rgba(0,104,56,0.04)]' : ''}`} + > + + + + + + + ))} + +
RaceDistanceKenyan residentNon-resident{"What's Included"}
+
+ + {race.distance} {race.name} + + {race.hasPrizeMoney && ( + + Prize Money + + )} +
+

{race.description}

+
+ {race.category.distance_km ? `${race.category.distance_km} km` : race.distance} + + {race.inclusions.join(' · ')} +
+
+ + {/* Mobile Cards */} +
+ {races.map((race) => ( +
handleRaceRowClick(race.id)} + className={`bg-white rounded-xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.06)] cursor-pointer transition-all ${ + selectedRace === race.id ? 'ring-2 ring-[#006838]' : '' + }`} + > +
+
+
+ {race.distance} + {race.hasPrizeMoney && ( + + Prize Money + + )} +
+

{race.name}

+
+ +
+

+ {race.inclusions.join(' · ')} +

+
+ ))} +
+ +

+ {treesText ? `Every entry includes ${treesText.replace(' planted in your name', '')} planted in the ASAL conservation area. ` : ''}Payment accepted via M-PESA and card. Contact us for group and corporate relay pricing. +

+ +
+ +
+
+
+ + {/* SECTION 3: TWO-COLUMN FORM LAYOUT */} +
+
+ {( +
+ {/* Mobile Summary Accordion */} +
+ + {mobileSummaryOpen && ( +
+ +
+ )} +
+ + {/* LEFT: FORM */} +
+
+ {/* Step Indicator */} +
+ {[ + { num: 1, label: 'Choose Race' }, + { num: 2, label: 'Your Details' }, + { num: 3, label: 'Payment' }, + ].map((step, index) => ( +
+
+
step.num + ? 'bg-[#3BB34A] text-white' + : currentStep === step.num + ? 'bg-[#006838] text-white' + : 'bg-[#E5E7EB] text-[#687279]' + }`}> + {currentStep > step.num ? : step.num} +
+ +
+ {index < 2 && ( +
step.num ? 'bg-[#006838]' : 'bg-[#E5E7EB]' + }`} /> + )} +
+ ))} +
+ + {/* STEP 1: CHOOSE RACE */} + {currentStep === 1 && ( +
+

+ Which race are you entering? +

+

+ Select your distance. Your entry fee will appear in the summary panel. +

+ + {/* Race Cards */} +
+ {races.length === 0 && ( +

+ Races for the next edition haven't been published yet. Join the waitlist below and we'll email you when entries open. +

+ )} + {races.map((race) => ( +
!race.soldOut && setSelectedRace(race.id)} + className={`relative border-2 rounded-[10px] p-5 cursor-pointer transition-all ${ + selectedRace === race.id + ? 'border-[#006838] bg-[rgba(0,104,56,0.04)]' + : 'border-[#E5E7EB] bg-white hover:border-[#006838]/40' + } ${race.hasPrizeMoney ? 'border-l-4 border-l-[#F0C000]' : ''}`} + > + {selectedRace === race.id && ( +
+ +
+ )} + {race.hasPrizeMoney && ( + + Prize Money + + )} +
+
+
+ {race.isVirtual && } + + {race.distance} + +
+ + {race.name} + +
+
+

+ {entryCategory === 'resident' ? 'Kenyan resident' : 'Non-resident'} +

+ +
+
+

+ {race.inclusions.join(' · ')} +

+
+ ))} +
+ + {/* Entry Category Toggle */} +
+ +

+ This affects your entry fee. The final amount is confirmed before you pay. +

+
+ + +
+
+ + +
+ )} + + {/* STEP 2: YOUR DETAILS */} + {currentStep === 2 && ( +
+

+ Tell us about yourself. +

+

+ Your details are used for your race bib, chip timing, and post-race certificate. +

+ +
+ {/* First Name */} +
+ + + {errors.firstName &&

{errors.firstName}

} +
+ + {/* Last Name */} +
+ + + {errors.lastName &&

{errors.lastName}

} +
+ + {/* ID/Passport */} +
+ + + {errors.idNumber &&

{errors.idNumber}

} +

Checked when you collect your race pack and for prize payments.

+
+ + {/* Date of Birth */} +
+ + + {errors.dob &&

{errors.dob}

} +

Used for race age limits and age-group results.

+
+ + {/* Gender */} +
+ +
+ + +
+

Results and prizes are ranked by gender.

+ {errors.gender &&

{errors.gender}

} +
+ + {/* Nationality */} +
+ + + {errors.nationality &&

{errors.nationality}

} +
+ + {/* Email - Full Width */} +
+ + + {errors.email &&

{errors.email}

} +
+ + {/* Phone */} +
+ + + {errors.phone &&

{errors.phone}

} +
+ + {/* WhatsApp */} +
+ + +
+ + {/* T-Shirt Size - Full Width */} +
+ + + {errors.tshirtSize &&

{errors.tshirtSize}

} +
+ + {/* Emergency contact */} +
+

Emergency contact

+

Someone we can call on race day if you need help. Not a runner in the same race, ideally.

+
+
+ + + {errors.emergencyName &&

{errors.emergencyName}

} +
+
+ + + {errors.emergencyPhone &&

{errors.emergencyPhone}

} +
+
+ + + {errors.emergencyRelationship &&

{errors.emergencyRelationship}

} +
+
+ +