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
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { PaymentStatus } from './payment-status'
export const metadata: Metadata = {
title: 'Payment | 10 Million Trees Marathon',
robots: { index: false },
}
export default function PaymentReturnPage() {
return (
<div className="min-h-screen bg-[#F9FAFB]">
<Navigation />
<section className="bg-[#006838] pt-32 pb-24 px-4">
<PaymentStatus />
</section>
<Footer />
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { CheckCircle2, Loader2, XCircle, Clock } from 'lucide-react'
import { formatMoney } from '@/lib/event-types'
interface StatusResponse {
status?: string
registration?: string
registration_status?: string
amount?: number
currency?: string
error?: string
}
type View = 'checking' | 'paid' | 'failed' | 'waiting' | 'error'
const POLL_MS = 3000
const MAX_POLLS = 20 // about a minute; M-PESA confirmations usually land well inside this
export function PaymentStatus() {
const [view, setView] = useState<View>('checking')
const [data, setData] = useState<StatusResponse>({})
const [reference, setReference] = useState<string | null>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
// Paystack sends the runner back with ?reference=... (and ?trxref=... as a copy)
const ref = params.get('reference') || params.get('trxref')
setReference(ref)
if (!ref) {
setView('error')
setData({ error: 'This page needs a payment reference. Open it from the payment page, or check your email.' })
return
}
let polls = 0
let timer: ReturnType<typeof setTimeout>
let stopped = false
const check = async () => {
polls += 1
try {
const res = await fetch(`/api/payment/status?reference=${encodeURIComponent(ref)}`, { cache: 'no-store' })
const body = (await res.json()) as StatusResponse
if (stopped) return
if (!res.ok) throw new Error(body.error || 'Could not check the payment.')
setData(body)
const status = (body.status || '').toLowerCase()
if (status === 'paid' || status === 'reconciled') return setView('paid')
if (status === 'failed' || status === 'abandoned') return setView('failed')
if (polls >= MAX_POLLS) return setView('waiting')
setView('checking')
} catch (err) {
if (stopped) return
if (polls >= MAX_POLLS) {
setData({ error: (err as Error).message })
return setView('error')
}
}
timer = setTimeout(check, POLL_MS)
}
check()
return () => {
stopped = true
clearTimeout(timer)
}
}, [])
const amount = formatMoney(data.amount ?? null, data.currency || 'KES')
return (
<div className="max-w-[520px] mx-auto bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] p-8 md:p-12 text-center" aria-live="polite">
{view === 'checking' && (
<>
<Loader2 className="w-12 h-12 text-[#006838] mx-auto mb-4 animate-spin" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">Confirming your payment</h1>
<p className="font-sans text-[#687279] text-[15px]">
We&apos;re checking with the payment provider. If you paid by M-PESA, this can take up to a minute. Keep this page open.
</p>
</>
)}
{view === 'paid' && (
<>
<CheckCircle2 className="w-14 h-14 text-[#3BB34A] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#006838] text-[28px] mb-3">You&apos;re in.</h1>
<p className="font-sans text-[#111518] text-[15px] mb-2">
Payment received{amount ? ` (${amount})` : ''}. Your entry{data.registration ? ` ${data.registration}` : ''} is confirmed.
</p>
<p className="font-sans text-[#687279] text-[14px] mb-8">
A confirmation email is on its way with your private My Race link, where you can see your bib and race pack status.
</p>
<Link href="/race-info" className="inline-flex items-center justify-center h-12 px-6 bg-[#006838] text-white font-heading font-bold text-sm rounded-lg hover:bg-[#005030] transition-colors">
Read the race information
</Link>
</>
)}
{view === 'failed' && (
<>
<XCircle className="w-14 h-14 text-[#c0392b] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">Payment not completed</h1>
<p className="font-sans text-[#687279] text-[15px] mb-8">
No money was taken for this attempt. Your details are saved{data.registration ? ` under entry ${data.registration}` : ''}, so you can try again without filling the form.
</p>
<Link href="/registration#registration-form" className="inline-flex items-center justify-center h-12 px-6 bg-[#FFF100] text-[#714508] font-heading font-bold text-sm rounded-lg hover:bg-[#3BB34A] hover:text-white transition-colors">
Try again
</Link>
</>
)}
{view === 'waiting' && (
<>
<Clock className="w-14 h-14 text-[#F0C000] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">Still waiting for confirmation</h1>
<p className="font-sans text-[#687279] text-[15px] mb-8">
The payment provider hasn&apos;t confirmed your payment yet. If money left your account, don&apos;t pay again: your entry is confirmed automatically when the confirmation arrives, and we&apos;ll email you.
</p>
<button onClick={() => window.location.reload()} className="inline-flex items-center justify-center h-12 px-6 border-2 border-[#006838] text-[#006838] font-heading font-bold text-sm rounded-lg hover:bg-[#006838] hover:text-white transition-colors">
Check again
</button>
</>
)}
{view === 'error' && (
<>
<XCircle className="w-14 h-14 text-[#687279] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">We couldn&apos;t check this payment</h1>
<p className="font-sans text-[#687279] text-[15px] mb-2">{data.error}</p>
{reference && <p className="font-sans text-[#687279] text-[13px] mb-6">Payment reference: {reference}</p>}
<a href="mailto:marathon@lukenyauniversity.ac.ke" className="font-heading font-semibold text-[#006838] text-sm underline">
Email us with your payment reference
</a>
</>
)}
</div>
)
}