Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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'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't confirmed your payment yet. If money left your account, don't pay again: your entry is confirmed automatically when the confirmation arrives, and we'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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user