- Remove FPX payment rail entirely (Malaysian, not UK) - Add volunteer portal (/v/[code]) with live pledge tracking - Add public event page (/e/[slug]) with progress bar + social proof - Add fundraiser leaderboard (/dashboard/events/[id]/leaderboard) - Add WhatsApp share buttons on confirmation, bank instructions, volunteer view - Enhanced Gift Aid UX with +25% bonus display and HMRC declaration text - Gift Aid report export (HMRC-ready CSV filter) - Volunteer view link + WhatsApp share on QR code cards - Updated home page: 4 personas, 3 UK payment rails, 8 features - Public event API endpoint with privacy-safe donor name truncation - Volunteer API with stats, conversion rate, auto-refresh
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
"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 { DirectDebitStep } from "./steps/direct-debit-step"
|
|
|
|
export type Rail = "bank" | "gocardless" | "card"
|
|
|
|
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
|
|
}
|
|
|
|
// Steps:
|
|
// 0 = Amount
|
|
// 1 = Payment method
|
|
// 2 = Identity (for bank transfer)
|
|
// 3 = Bank instructions
|
|
// 4 = Confirmation (card, DD)
|
|
// 5 = Card payment step
|
|
// 7 = Direct Debit step
|
|
|
|
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)
|
|
})
|
|
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,
|
|
card: 5,
|
|
gocardless: 7,
|
|
}
|
|
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)
|
|
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 shareUrl = eventInfo?.qrSourceId ? `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}` : undefined
|
|
|
|
const steps: Record<number, React.ReactNode> = {
|
|
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} />,
|
|
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
|
2: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
|
|
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 || ""} shareUrl={shareUrl} />,
|
|
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
|
7: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
|
}
|
|
|
|
const backableSteps = new Set([1, 2, 5, 7])
|
|
const getBackStep = (s: number): number => {
|
|
if (s === 5 || s === 7) return 1
|
|
return s - 1
|
|
}
|
|
const progressPercent = step >= 3 ? 100 : step >= 2 ? 66 : step >= 1 ? 33 : 10
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<div className="px-4 pb-8">{steps[step]}</div>
|
|
|
|
{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>
|
|
)
|
|
}
|