'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)]' : ''}`} > ))}
Race Distance Kenyan resident Non-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}

}