feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View File

@@ -0,0 +1,210 @@
"use client"
import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import { AmountStep } from "./steps/amount-step"
import { PaymentStep } from "./steps/payment-step"
import { IdentityStep } from "./steps/identity-step"
import { ConfirmationStep } from "./steps/confirmation-step"
import { BankInstructionsStep } from "./steps/bank-instructions-step"
import { CardPaymentStep } from "./steps/card-payment-step"
import { FpxPaymentStep } from "./steps/fpx-payment-step"
import { DirectDebitStep } from "./steps/direct-debit-step"
export type Rail = "bank" | "gocardless" | "card" | "fpx"
export interface PledgeData {
amountPence: number
rail: Rail
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}
interface EventInfo {
id: string
name: string
organizationName: string
qrSourceId: string | null
qrSourceLabel: string | null
}
// Step indices:
// 0 = Amount selection
// 1 = Payment method selection
// 2 = Identity (for bank transfer)
// 3 = Bank instructions
// 4 = Confirmation (generic — card, DD, FPX)
// 5 = Card payment step
// 6 = FPX payment step
// 7 = Direct Debit step
const STEP_TO_RAIL: Record<number, number> = { 5: 1, 6: 1, 7: 1 } // maps back to payment selection
export default function PledgePage() {
const params = useParams()
const token = params.token as string
const [step, setStep] = useState(0)
const [eventInfo, setEventInfo] = useState<EventInfo | null>(null)
const [pledgeData, setPledgeData] = useState<PledgeData>({
amountPence: 0,
rail: "bank",
donorName: "",
donorEmail: "",
donorPhone: "",
giftAid: false,
})
const [pledgeResult, setPledgeResult] = useState<{
id: string
reference: string
bankDetails?: {
bankName: string
sortCode: string
accountNo: string
accountName: string
}
} | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
useEffect(() => {
fetch(`/api/qr/${token}`)
.then((r) => r.json())
.then((data) => {
if (data.error) setError(data.error)
else setEventInfo(data)
setLoading(false)
})
.catch(() => {
setError("Unable to load pledge page")
setLoading(false)
})
// Track pledge_start
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "pledge_start", metadata: { token } }),
}).catch(() => {})
}, [token])
const handleAmountSelected = (amountPence: number) => {
setPledgeData((d) => ({ ...d, amountPence }))
setStep(1)
}
const handleRailSelected = (rail: Rail) => {
setPledgeData((d) => ({ ...d, rail }))
const railStepMap: Record<Rail, number> = {
bank: 2, // → identity step → bank instructions
card: 5, // → card payment step (combined identity + card)
fpx: 6, // → FPX step (bank selection + identity + redirect)
gocardless: 7, // → direct debit step (bank details + mandate)
}
setStep(railStepMap[rail])
}
const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean }) => {
const finalData = { ...pledgeData, ...identity }
setPledgeData(finalData)
try {
const res = await fetch("/api/pledges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...finalData,
eventId: eventInfo?.id,
qrSourceId: eventInfo?.qrSourceId,
}),
})
const result = await res.json()
if (result.error) {
setError(result.error)
return
}
setPledgeResult(result)
// Bank rail shows bank instructions; everything else shows generic confirmation
setStep(finalData.rail === "bank" ? 3 : 4)
} catch {
setError("Something went wrong. Please try again.")
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<div className="animate-pulse text-trust-blue text-lg font-medium">Loading...</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<div className="text-center space-y-4">
<div className="text-6xl">😔</div>
<h1 className="text-xl font-bold text-gray-900">Something went wrong</h1>
<p className="text-muted-foreground">{error}</p>
</div>
</div>
)
}
const steps: Record<number, React.ReactNode> = {
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} />,
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
2: <IdentityStep onSubmit={submitPledge} />,
3: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} />,
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} />,
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
6: <FpxPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} onComplete={submitPledge} />,
7: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
}
// Determine which steps allow back navigation
const backableSteps = new Set([1, 2, 5, 6, 7])
const getBackStep = (current: number): number => {
if (current in STEP_TO_RAIL) return STEP_TO_RAIL[current] // rail-specific steps → payment selection
return current - 1
}
// Progress calculation: steps 0-2 map linearly, 3+ means done
const progressSteps = step >= 3 ? 3 : Math.min(step, 2) + 1
const progressPercent = step >= 5 ? 66 : (progressSteps / 3) * 100 // rail steps show 2/3 progress
return (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
{/* Progress bar */}
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
<div
className="h-full bg-trust-blue transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Header */}
<div className="pt-6 pb-2 px-4 text-center">
<p className="text-sm text-muted-foreground">{eventInfo?.organizationName}</p>
<p className="text-xs text-muted-foreground/60">{eventInfo?.qrSourceLabel || ""}</p>
</div>
{/* Step content */}
<div className="px-4 pb-8">
{steps[step]}
</div>
{/* Back button */}
{backableSteps.has(step) && (
<div className="fixed bottom-6 left-4">
<button
onClick={() => setStep(getBackStep(step))}
className="text-sm text-muted-foreground hover:text-foreground transition-colors tap-target flex items-center gap-1"
>
Back
</button>
</div>
)}
</div>
)
}