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
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import { METHODS, frappeCall } from '@/lib/lukenya-api'
// Current published legal documents (terms, indemnity, privacy, optional consents).
export async function GET() {
try {
const docs = await frappeCall<unknown[]>(METHODS.legalDocuments, { revalidate: 60 })
return NextResponse.json(docs || [], { headers: { 'Cache-Control': 'public, max-age=60' } })
} catch {
return NextResponse.json([], { status: 503 })
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall } from '@/lib/lukenya-api'
// POST { token } → the runner's entry. POST { email } → email a fresh My Race link.
// Tokens travel in the request body, not the URL, so they don't end up in access logs.
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}))
try {
if (typeof body.token === 'string' && body.token) {
const entry = await frappeCall<Record<string, unknown>>(METHODS.myRace, { args: { token: body.token }, post: true })
return NextResponse.json(entry, { headers: { 'Cache-Control': 'no-store' } })
}
if (typeof body.email === 'string' && body.email.includes('@')) {
const result = await frappeCall<{ message: string }>(METHODS.myRaceLink, {
args: { email: body.email.trim().toLowerCase() },
post: true,
})
return NextResponse.json(result)
}
return NextResponse.json({ error: 'Enter the email you registered with.' }, { status: 400 })
} catch (err) {
const status = err instanceof FrappeError && err.status < 500 ? err.status : 502
return NextResponse.json({ error: (err as Error).message }, { status })
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall, registrationEnabled } from '@/lib/lukenya-api'
// Asks the backend to open a Paystack checkout for a saved entry and returns its link.
export async function POST(req: Request) {
if (!registrationEnabled()) {
return NextResponse.json({ error: 'Online payment is not open yet.' }, { status: 403 })
}
const body = await req.json().catch(() => ({}))
const registration = typeof body.registration === 'string' ? body.registration : ''
if (!registration) return NextResponse.json({ error: 'No entry to pay for.' }, { status: 400 })
try {
const result = await frappeCall<{ authorization_url: string; reference: string }>(METHODS.startPayment, {
args: {
registration,
payment_token: typeof body.payment_token === 'string' ? body.payment_token : undefined,
channel: body.channel === 'card' ? 'card' : 'mpesa',
},
post: true,
})
if (!result?.authorization_url) throw new Error('The payment page could not be opened.')
return NextResponse.json(result)
} catch (err) {
const status = err instanceof FrappeError && err.status < 500 ? 400 : 502
return NextResponse.json({ error: (err as Error).message }, { status })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall } from '@/lib/lukenya-api'
// Polled by /payment/return. The backend checks with Paystack itself before answering,
// so the browser being sent back here never confirms a payment on its own.
export async function GET(req: Request) {
const reference = new URL(req.url).searchParams.get('reference') || ''
if (!/^[A-Za-z0-9._-]{4,100}$/.test(reference)) {
return NextResponse.json({ error: 'Missing or invalid payment reference.' }, { status: 400 })
}
try {
const result = await frappeCall<Record<string, unknown>>(METHODS.paymentStatus, { args: { reference } })
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
} catch (err) {
const status = (err as FrappeError).status
if (status === 404) return NextResponse.json({ error: 'We could not find this payment.' }, { status: 404 })
// The server re-checks with Paystack in the background; keep the runner on the waiting screen.
return NextResponse.json({ status: 'Pending' }, { headers: { 'Cache-Control': 'no-store' } })
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall, registrationEnabled } from '@/lib/lukenya-api'
// The browser posts the form here; this route forwards it to Frappe. The backend
// re-validates everything and decides the price, so nothing here is trusted for money.
const ALLOWED = [
'event_edition', 'race_category', 'residency', 'first_name', 'last_name', 'id_number',
'date_of_birth', 'gender', 'nationality', 'email', 'phone', 'whatsapp', 'shirt_size',
'emergency_contact_name', 'emergency_contact_phone', 'guardian_name', 'guardian_phone',
'indemnity_accepted', 'communication_consent', 'preferred_payment', 'emergency_contact_relationship',
'support_request', 'consents',
] as const
const REQUIRED = [
'race_category', 'first_name', 'last_name', 'email', 'phone',
'emergency_contact_name', 'emergency_contact_phone',
] as const
export async function POST(req: Request) {
if (!registrationEnabled()) {
return NextResponse.json({ error: 'Online registration is not open yet.' }, { status: 403 })
}
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'The form data could not be read.' }, { status: 400 })
}
const payload: Record<string, unknown> = {}
for (const key of ALLOWED) {
const value = body[key]
if (value === undefined || value === null) continue
payload[key] = typeof value === 'string' ? value.trim().slice(0, key === 'support_request' ? 1000 : 500) : value
}
payload.event_edition = payload.event_edition || process.env.FRAPPE_EDITION
const missing = REQUIRED.filter((k) => !payload[k])
if (!payload.event_edition) missing.unshift('race edition' as never)
if (payload.indemnity_accepted !== true) missing.push('indemnity acceptance' as never)
if (missing.length) {
return NextResponse.json({ error: `Please complete: ${missing.join(', ').replace(/_/g, ' ')}.` }, { status: 400 })
}
try {
const result = await frappeCall<{
registration: string
amount_due: number
currency: string
payment_token?: string
}>(METHODS.createRegistration, { args: { payload }, post: true })
return NextResponse.json({
registration: result.registration,
amount_due: result.amount_due,
currency: result.currency,
payment_token: result.payment_token,
})
} catch (err) {
const status = err instanceof FrappeError && err.status < 500 ? 400 : 502
return NextResponse.json({ error: (err as Error).message }, { status })
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server'
import { revalidatePath } from 'next/cache'
// Called by the Frappe backend after race data changes, so the site updates at once
// instead of within the 60-second refresh window.
export async function POST(req: Request) {
const secret = process.env.REVALIDATE_SECRET
if (!secret || req.headers.get('x-revalidate-secret') !== secret) {
return NextResponse.json({ error: 'Not allowed.' }, { status: 401 })
}
revalidatePath('/', 'layout')
return NextResponse.json({ revalidated: true, at: new Date().toISOString() })
}