'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 ? (
{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.