// Shapes of the data the Frappe backend returns for the public site, plus small // formatting helpers. Safe to import from both server and client components. export type RegistrationState = 'open' | 'upcoming' | 'closed' | 'unavailable' export type Residency = 'resident' | 'non-resident' export interface PublicEdition { name: string edition_name: string edition_number: number | null event_date: string | null venue: string | null county: string | null description: string | null registration_open: string | null registration_close: string | null early_bird_ends: string | null status: string registration_state: RegistrationState trees_commitment_per_entry: number | null } export interface CategoryPrices { kenyan: number | null non_resident: number | null early_bird: number | null early_bird_active: boolean } export interface PublicCategory { name: string category_code: string category_name: string discipline: string distance_km: number | null distance_label: string short_description: string start_time: string | null cutoff_hours: number | null min_age: number | null elevation_gain_m: number | null eligibility: string inclusions: string[] prize_eligible: boolean is_featured: boolean sold_out: boolean closed?: boolean prices: CategoryPrices } export interface PublicEvent { edition: PublicEdition | null categories: PublicCategory[] currency: string // live: get_public_event answered. fallback: older get_event_categories answered. // unavailable: the backend could not be reached. source: 'live' | 'fallback' | 'unavailable' } export interface PrizeRow { position: number amount: number } export interface PrizeDivision { division: string age_group: string | null places: PrizeRow[] } export interface PrizeCategory { race_category: string category_name: string distance_label: string divisions: PrizeDivision[] } export interface PublicPrizes { currency: string categories: PrizeCategory[] } export const EMPTY_EVENT: PublicEvent = { edition: null, categories: [], currency: 'KES', source: 'unavailable', } // ---- formatting ------------------------------------------------------------------------ export function formatMoney(amount: number | null | undefined, currency = 'KES'): string | null { if (amount === null || amount === undefined || Number.isNaN(amount) || amount <= 0) return null return `${currency} ${Math.round(amount).toLocaleString('en-KE')}` } function parseDate(value: string | null | undefined): Date | null { if (!value) return null // Frappe sends "2027-03-20" or "2027-03-20 06:00:00" const d = new Date(value.includes(' ') ? value.replace(' ', 'T') : `${value}T00:00:00`) return Number.isNaN(d.getTime()) ? null : d } export function formatEventDate(value: string | null | undefined): string | null { const d = parseDate(value) return d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : null } export function formatDateTime(value: string | null | undefined): string | null { const d = parseDate(value) if (!d) return null return d.toLocaleString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }) } // "05:30:00" -> "5:30 AM" export function formatClock(value: string | null | undefined): string | null { if (!value) return null const [h, m] = value.split(':').map(Number) if (Number.isNaN(h)) return null const suffix = h >= 12 ? 'PM' : 'AM' const hour = h % 12 === 0 ? 12 : h % 12 return `${hour}:${String(m || 0).padStart(2, '0')} ${suffix}` } export function formatCutoff(hours: number | null | undefined): string | null { if (!hours) return null return hours === 1 ? '1 hour' : `${hours} hours` } export function distanceLabel(distanceKm: number | null | undefined, discipline?: string): string { if ((discipline || '').toLowerCase() === 'virtual') return 'VIRTUAL' if (!distanceKm) return (discipline || 'RACE').toUpperCase() const rounded = Number.isInteger(distanceKm) ? distanceKm : Math.floor(distanceKm) return `${rounded}KM` } export function venueLine(edition: PublicEdition | null): string | null { if (!edition) return null return [edition.venue, edition.county].filter(Boolean).join(', ') || null } // Mirrors the backend rule so the page can show an estimate. The server decides the // amount actually charged, and the form shows that figure before payment. export function displayPrice(category: PublicCategory, residency: Residency): number | null { const p = category.prices if (residency === 'non-resident') return p.non_resident || p.kenyan if (p.early_bird_active && p.early_bird) return p.early_bird return p.kenyan } export function registrationIsOpen(event: PublicEvent): boolean { return event.edition?.registration_state === 'open' } export function ordinal(n: number | null | undefined): string | null { if (!n) return null const rem100 = n % 100 const suffix = rem100 >= 11 && rem100 <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' } as Record)[n % 10] || 'th' return `${n}${suffix}` } // "5th Edition", or the edition's own name when no number is set. export function editionLabel(edition: PublicEdition | null): string | null { if (!edition) return null const n = ordinal(edition.edition_number) return n ? `${n} Edition` : edition.edition_name } const NUMBER_WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'] export function countWord(n: number): string { return NUMBER_WORDS[n] || String(n) } export function findPrize( prizes: PublicPrizes | null, division: string, position = 1, raceCategory?: string, ): number | null { if (!prizes) return null for (const cat of prizes.categories) { if (raceCategory && cat.race_category !== raceCategory) continue for (const d of cat.divisions) { if (d.division !== division) continue const row = d.places.find((p) => p.position === position) if (row && row.amount > 0) return row.amount } } return null } export function registrationStatusText(event: PublicEvent): string { const e = event.edition const label = editionLabel(e) switch (e?.registration_state) { case 'open': return label ? `Registration open: ${label}` : 'Registration open' case 'upcoming': { const opens = formatDateTime(e.registration_open) return opens ? `Registration opens ${opens}` : 'Registration opens soon' } case 'closed': return 'Registration is closed' default: return 'Registration opens soon' } }