235 lines
9.4 KiB
TypeScript
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'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't share the link from your email.{' '}
|
|
<Link href="/race-info" className="text-[#006838] underline">Race information</Link>
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|