210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
// 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<number, string>)[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'
|
|
}
|
|
}
|