Initial commit

This commit is contained in:
KabundiNjuki
2026-10-05 16:40:36 +03:00
commit ff743a1397
108 changed files with 15390 additions and 0 deletions
+211
View File
@@ -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'
}