Files
lukenyaMarathon_frontend/app/my-race/my-race-view.tsx
T
KabundiNjuki ff743a1397 Initial commit
2026-10-05 16:40:36 +03:00

235 lines
9.4 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Loader2 } from 'lucide-react'
import { formatClock, formatEventDate, formatMoney } from '@/lib/event-types'
interface Entry {
registration: string
registration_status: string
payment_status: string
amount_due: number
amount_paid: number
currency: string
can_pay: boolean
next_action?: string
runner: { first_name?: string; full_name?: string }
edition: { edition_name?: string; event_date?: string | null; venue?: string; county?: string }
race: { category_name?: string; distance_label?: string; start_time?: string | null }
bib_number: string | null
race_pack_status: string
check_in_status: string
result: { finish_status: string; chip_time: number | null; gun_time: number | null; overall_rank?: number } | null
payments: { date: string; method: string | null; amount: number; status: string; reference: string }[]
}
const STORE = 'lukenya-my-race-token'
function duration(seconds: number | null) {
if (!seconds) return null
const s = Math.round(seconds)
return `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
async function post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.')
return data as T
}
export function MyRaceView() {
const [token, setToken] = useState<string | null>(null)
const [entry, setEntry] = useState<Entry | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [email, setEmail] = useState('')
const [sent, setSent] = useState<string | null>(null)
const [paying, setPaying] = useState(false)
useEffect(() => {
// Take the token out of the address bar so it isn't shared by accident, and keep it for this tab.
const url = new URL(window.location.href)
const fromUrl = url.searchParams.get('token')
if (fromUrl) {
try { sessionStorage.setItem(STORE, fromUrl) } catch { /* private mode */ }
url.searchParams.delete('token')
window.history.replaceState(null, '', url.pathname + url.search)
}
let saved: string | null = fromUrl
if (!saved) {
try { saved = sessionStorage.getItem(STORE) } catch { saved = null }
}
setToken(saved)
if (!saved) {
setLoading(false)
return
}
post<Entry>('/api/my-race', { token: saved })
.then(setEntry)
.catch((err) => {
setError((err as Error).message)
try { sessionStorage.removeItem(STORE) } catch { /* ignore */ }
})
.finally(() => setLoading(false))
}, [])
const requestLink = async () => {
setError(null)
try {
const res = await post<{ message: string }>('/api/my-race', { email })
setSent(res.message)
} catch (err) {
setError((err as Error).message)
}
}
const pay = async () => {
if (!entry || !token) return
setPaying(true)
setError(null)
try {
const res = await post<{ authorization_url: string }>('/api/payment/start', {
registration: entry.registration,
payment_token: token,
channel: 'mpesa',
})
window.location.assign(res.authorization_url)
} catch (err) {
setError((err as Error).message)
setPaying(false)
}
}
const card = 'max-w-[560px] mx-auto bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] p-6 md:p-10'
if (loading) {
return (
<div className={`${card} text-center`}>
<Loader2 className="w-10 h-10 text-[#006838] mx-auto animate-spin" />
<p className="font-sans text-[#687279] mt-4">Loading your entry…</p>
</div>
)
}
if (!entry) {
return (
<div className={card}>
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-2">My Race</h1>
<p className="font-sans text-[#687279] text-[15px] mb-6">
Enter the email you registered with and we&apos;ll send you a private link to your entry.
</p>
{error && <p role="alert" className="mb-4 font-sans text-[13px] text-[#c0392b]">{error}</p>}
{sent ? (
<p className="font-sans text-[#006838] text-[15px]">{sent}</p>
) : (
<div className="flex flex-col sm:flex-row gap-3">
<label htmlFor="my-race-email" className="sr-only">Email</label>
<input
id="my-race-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 px-4 h-12 rounded-lg border border-[#D1D5DB] font-sans text-[15px] focus:border-[#006838] focus:ring-2 focus:ring-[rgba(0,104,56,0.12)] outline-none"
/>
<button
onClick={requestLink}
disabled={!email.includes('@')}
className="h-12 px-6 rounded-lg bg-[#006838] text-white font-heading font-bold text-sm disabled:bg-[#E5E7EB] disabled:text-[#687279]"
>
Email my link
</button>
</div>
)}
</div>
)
}
const confirmed = entry.registration_status === 'Confirmed' || entry.registration_status === 'Completed'
const balance = Math.max(entry.amount_due - entry.amount_paid, 0)
const rows: [string, string | null | undefined][] = [
['Race', [entry.race.distance_label, entry.race.category_name].filter(Boolean).join(' ')],
['Race day', [formatEventDate(entry.edition.event_date), formatClock(entry.race.start_time) && `start ${formatClock(entry.race.start_time)}`].filter(Boolean).join(', ')],
['Venue', [entry.edition.venue, entry.edition.county].filter(Boolean).join(', ')],
['Entry', entry.registration],
['Bib', entry.bib_number || 'Not allocated yet'],
['Race pack', entry.race_pack_status],
['Check-in', entry.check_in_status],
]
return (
<div className={card}>
<p className="font-sans text-[#687279] text-[14px]">{entry.edition.edition_name}</p>
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-1">
{entry.runner.first_name ? `${entry.runner.first_name}, ` : ''}
{confirmed ? "you're in." : 'your entry is not confirmed yet.'}
</h1>
<p className="font-sans text-[15px] mb-6 text-[#687279]">
{confirmed
? `Paid ${formatMoney(entry.amount_paid, entry.currency) || ''}.`
: balance > 0
? `${formatMoney(balance, entry.currency)} is still to pay. Your place is confirmed once payment reaches us.`
: `Status: ${entry.registration_status}.`}
</p>
{entry.next_action && (
<p className="mb-6 rounded-lg bg-[#F2F8F4] border border-[#3BB34A]/30 px-4 py-3 font-sans text-[14px] text-[#111518]">
<span className="font-heading font-bold text-[#006838]">Next: </span>{entry.next_action}
</p>
)}
{entry.can_pay && (
<button
onClick={pay}
disabled={paying}
className="w-full h-12 mb-6 rounded-lg bg-[#FFF100] text-[#714508] font-heading font-bold text-sm uppercase tracking-wide hover:bg-[#3BB34A] hover:text-white inline-flex items-center justify-center gap-2"
>
{paying && <Loader2 className="w-4 h-4 animate-spin" />}
{paying ? 'Opening payment…' : `Pay ${formatMoney(balance, entry.currency)}`}
</button>
)}
{error && <p role="alert" className="mb-4 font-sans text-[13px] text-[#c0392b]">{error}</p>}
<dl className="divide-y divide-[#E5E7EB] border-y border-[#E5E7EB]">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 py-3">
<dt className="font-heading font-semibold text-[13px] text-[#687279]">{label}</dt>
<dd className="font-sans text-[14px] text-[#111518] text-right">{value || '—'}</dd>
</div>
))}
{entry.result && (
<div className="flex justify-between gap-4 py-3">
<dt className="font-heading font-semibold text-[13px] text-[#687279]">Result</dt>
<dd className="font-sans text-[14px] text-[#111518] text-right">
{entry.result.finish_status}
{duration(entry.result.chip_time || entry.result.gun_time) ? `, ${duration(entry.result.chip_time || entry.result.gun_time)}` : ''}
{entry.result.overall_rank ? `, position ${entry.result.overall_rank}` : ''}
</dd>
</div>
)}
</dl>
{entry.payments.length > 0 && (
<div className="mt-6">
<h2 className="font-heading font-bold text-[#111518] text-[15px] mb-2">Payments</h2>
<ul className="space-y-1">
{entry.payments.map((p) => (
<li key={p.reference || p.date} className="flex justify-between font-sans text-[13px] text-[#687279]">
<span>{formatEventDate(p.date)} · {p.method || 'Online'}</span>
<span>{formatMoney(p.amount, entry.currency)} · {p.status}</span>
</li>
))}
</ul>
</div>
)}
<p className="mt-8 font-sans text-[12px] text-[#687279]">
This page is private to you. Don&apos;t share the link from your email.{' '}
<Link href="/race-info" className="text-[#006838] underline">Race information</Link>
</p>
</div>
)
}