Initial commit
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Server-side access to the Frappe backend. Only import this from server components and
|
||||
// route handlers: it reads secrets from the environment.
|
||||
|
||||
import { cache } from 'react'
|
||||
import {
|
||||
EMPTY_EVENT,
|
||||
distanceLabel,
|
||||
type PublicCategory,
|
||||
type PublicEvent,
|
||||
type PublicPrizes,
|
||||
} from './event-types'
|
||||
|
||||
const APP = 'lukenya_marathon.lukenya_marathon'
|
||||
export const REVALIDATE_SECONDS = 60
|
||||
|
||||
export const METHODS = {
|
||||
publicEvent: `${APP}.api.get_public_event`,
|
||||
publicPrizes: `${APP}.api.get_public_prizes`,
|
||||
eventCategories: `${APP}.api.get_event_categories`, // older endpoint, used as a fallback
|
||||
createRegistration: `${APP}.api.create_registration`,
|
||||
startPayment: `${APP}.payments.start_payment`,
|
||||
paymentStatus: `${APP}.payments.check_payment_status`,
|
||||
myRace: `${APP}.my_race.get_my_race`,
|
||||
myRaceLink: `${APP}.my_race.request_my_race_link`,
|
||||
legalDocuments: 'lukenya_marathon.website_content.api.get_legal_documents',
|
||||
} as const
|
||||
|
||||
export class FrappeError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
const url = process.env.FRAPPE_URL
|
||||
if (!url) throw new FrappeError('FRAPPE_URL is not set', 500)
|
||||
return url.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { Accept: 'application/json' }
|
||||
const key = process.env.FRAPPE_API_KEY
|
||||
const secret = process.env.FRAPPE_API_SECRET
|
||||
if (key && secret) h.Authorization = `token ${key}:${secret}`
|
||||
return h
|
||||
}
|
||||
|
||||
// Frappe puts user-facing errors in _server_messages: a JSON list of JSON strings.
|
||||
function readFrappeError(body: unknown, status: number): string {
|
||||
const b = (body || {}) as Record<string, unknown>
|
||||
try {
|
||||
if (typeof b._server_messages === 'string') {
|
||||
const list = JSON.parse(b._server_messages) as string[]
|
||||
const messages = list
|
||||
.map((m) => {
|
||||
try { return (JSON.parse(m) as { message?: string }).message || m } catch { return m }
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (typeof b.message === 'string') return b.message
|
||||
if (typeof b.exc_type === 'string') return `Request failed (${b.exc_type})`
|
||||
return `Request failed (${status})`
|
||||
}
|
||||
|
||||
interface CallOptions {
|
||||
args?: Record<string, unknown>
|
||||
post?: boolean
|
||||
revalidate?: number | false
|
||||
}
|
||||
|
||||
export async function frappeCall<T>(method: string, opts: CallOptions = {}): Promise<T> {
|
||||
const { args = {}, post = false, revalidate = false } = opts
|
||||
let url = `${baseUrl()}/api/method/${method}`
|
||||
const init: RequestInit & { next?: { revalidate: number | false; tags?: string[] } } = {
|
||||
method: post ? 'POST' : 'GET',
|
||||
headers: headers(),
|
||||
}
|
||||
|
||||
if (post) {
|
||||
init.headers = { ...headers(), 'Content-Type': 'application/json' }
|
||||
init.body = JSON.stringify(args)
|
||||
init.cache = 'no-store'
|
||||
} else {
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, typeof v === 'string' ? v : JSON.stringify(v))
|
||||
}
|
||||
if ([...qs.keys()].length) url += `?${qs.toString()}`
|
||||
if (revalidate === false) init.cache = 'no-store'
|
||||
else init.next = { revalidate, tags: ['lukenya'] }
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15000) })
|
||||
let body: unknown = null
|
||||
try { body = await res.json() } catch { /* non-JSON error page */ }
|
||||
if (!res.ok) {
|
||||
const detail = readFrappeError(body, res.status)
|
||||
if (res.status >= 500) console.error(`[lukenya] ${method} failed (${res.status}): ${detail}`)
|
||||
throw new FrappeError(res.status >= 500 ? 'Our booking system had a problem. Please try again in a moment.' : detail, res.status)
|
||||
}
|
||||
return (body as { message: T }).message
|
||||
}
|
||||
|
||||
// ---- public event ----------------------------------------------------------------------
|
||||
|
||||
function edition(): string | undefined {
|
||||
return process.env.FRAPPE_EDITION || undefined
|
||||
}
|
||||
|
||||
interface LegacyCategory {
|
||||
name: string
|
||||
category_code: string
|
||||
category_name: string
|
||||
discipline: string
|
||||
distance_km: number | null
|
||||
start_time: string | null
|
||||
cutoff_hours: number | null
|
||||
eligibility: string | null
|
||||
kenyan_price: number | null
|
||||
non_resident_price: number | null
|
||||
early_bird_price: number | null
|
||||
prize_eligible: number
|
||||
inclusions: string | null
|
||||
}
|
||||
|
||||
function fromLegacy(rows: LegacyCategory[]): PublicCategory[] {
|
||||
return rows.map((c) => ({
|
||||
name: c.name,
|
||||
category_code: c.category_code,
|
||||
category_name: c.category_name,
|
||||
discipline: c.discipline,
|
||||
distance_km: c.distance_km,
|
||||
distance_label: distanceLabel(c.distance_km, c.discipline),
|
||||
short_description: '',
|
||||
start_time: c.start_time,
|
||||
cutoff_hours: c.cutoff_hours,
|
||||
min_age: null,
|
||||
elevation_gain_m: null,
|
||||
eligibility: c.eligibility || '',
|
||||
inclusions: (c.inclusions || '').split('\n').map((s) => s.trim()).filter(Boolean),
|
||||
prize_eligible: Boolean(c.prize_eligible),
|
||||
is_featured: false,
|
||||
sold_out: false,
|
||||
prices: {
|
||||
kenyan: c.kenyan_price,
|
||||
non_resident: c.non_resident_price,
|
||||
early_bird: c.early_bird_price,
|
||||
// The old endpoint doesn't say whether early bird is running, so don't promise it.
|
||||
early_bird_active: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function normalise(event: PublicEvent): PublicEvent {
|
||||
return {
|
||||
...event,
|
||||
currency: event.currency || 'KES',
|
||||
categories: (event.categories || []).map((c) => ({
|
||||
...c,
|
||||
distance_label: c.distance_label || distanceLabel(c.distance_km, c.discipline),
|
||||
inclusions: Array.isArray(c.inclusions) ? c.inclusions : [],
|
||||
short_description: c.short_description || '',
|
||||
eligibility: c.eligibility || '',
|
||||
prize_eligible: Boolean(c.prize_eligible),
|
||||
is_featured: Boolean(c.is_featured),
|
||||
sold_out: Boolean(c.sold_out),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const getPublicEvent = cache(async (): Promise<PublicEvent> => {
|
||||
try {
|
||||
const event = await frappeCall<PublicEvent>(METHODS.publicEvent, {
|
||||
args: { edition: edition() },
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
})
|
||||
return normalise({ ...event, source: 'live' })
|
||||
} catch (err) {
|
||||
console.warn('[lukenya] get_public_event failed, trying get_event_categories:', (err as Error).message)
|
||||
}
|
||||
try {
|
||||
const rows = await frappeCall<LegacyCategory[]>(METHODS.eventCategories, {
|
||||
args: { event_edition: edition() },
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
})
|
||||
return { edition: null, categories: fromLegacy(rows || []), currency: 'KES', source: 'fallback' }
|
||||
} catch (err) {
|
||||
console.error('[lukenya] backend unavailable:', (err as Error).message)
|
||||
return EMPTY_EVENT
|
||||
}
|
||||
})
|
||||
|
||||
export const getPublicPrizes = cache(async (): Promise<PublicPrizes | null> => {
|
||||
try {
|
||||
return await frappeCall<PublicPrizes>(METHODS.publicPrizes, {
|
||||
args: { edition: edition() },
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[lukenya] get_public_prizes failed:', (err as Error).message)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export function registrationEnabled(): boolean {
|
||||
return process.env.REGISTRATION_ENABLED === 'true'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user