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,12 @@
export default function PledgeLoading() {
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="text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-trust-blue/10 animate-pulse">
<div className="w-8 h-8 rounded-full bg-trust-blue/30" />
</div>
<p className="text-muted-foreground animate-pulse">Loading pledge page...</p>
</div>
</div>
)
}

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>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
const PRESETS = [1000, 2000, 5000, 10000, 25000, 50000] // pence
interface Props {
onSelect: (amountPence: number) => void
eventName: string
}
export function AmountStep({ onSelect, eventName }: Props) {
const [custom, setCustom] = useState("")
const [selected, setSelected] = useState<number | null>(null)
const handlePreset = (amount: number) => {
setSelected(amount)
setCustom("")
}
const handleCustomChange = (value: string) => {
const clean = value.replace(/[^0-9.]/g, "")
setCustom(clean)
setSelected(null)
}
const handleContinue = () => {
const amount = selected || Math.round(parseFloat(custom) * 100)
if (amount >= 100) onSelect(amount)
}
const isValid = selected || (custom && parseFloat(custom) >= 1)
return (
<div className="max-w-md mx-auto pt-4 space-y-8">
<div className="text-center space-y-2">
<h1 className="text-3xl font-extrabold text-gray-900">
Make a Pledge
</h1>
<p className="text-muted-foreground">
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Presets */}
<div className="grid grid-cols-3 gap-3">
{PRESETS.map((amount) => (
<button
key={amount}
onClick={() => handlePreset(amount)}
className={`
tap-target rounded-2xl border-2 py-4 text-center font-bold text-lg transition-all
${selected === amount
? "border-trust-blue bg-trust-blue text-white shadow-lg shadow-trust-blue/25 scale-[1.02]"
: "border-gray-200 bg-white text-gray-900 hover:border-trust-blue/50 active:scale-[0.98]"
}
`}
>
£{amount / 100}
</button>
))}
</div>
{/* Custom */}
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Or enter a custom amount</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl font-bold text-gray-400">£</span>
<Input
type="text"
inputMode="decimal"
placeholder="0.00"
value={custom}
onChange={(e) => handleCustomChange(e.target.value)}
className="pl-10 h-16 text-2xl font-bold text-center rounded-2xl"
/>
</div>
</div>
{/* Continue */}
<Button
size="xl"
className="w-full"
disabled={!isValid}
onClick={handleContinue}
>
Continue
</Button>
<p className="text-center text-xs text-muted-foreground">
You won&apos;t be charged now. Choose how to pay next.
</p>
</div>
)
}

View File

@@ -0,0 +1,165 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Check, Copy, ExternalLink } from "lucide-react"
interface Props {
pledge: {
id: string
reference: string
bankDetails?: {
bankName: string
sortCode: string
accountNo: string
accountName: string
}
}
amount: number
eventName: string
}
export function BankInstructionsStep({ pledge, amount, eventName }: Props) {
const [copied, setCopied] = useState(false)
const [markedPaid, setMarkedPaid] = useState(false)
const copyReference = async () => {
await navigator.clipboard.writeText(pledge.reference)
setCopied(true)
setTimeout(() => setCopied(false), 3000)
// Track
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "instruction_copy_clicked", pledgeId: pledge.id }),
}).catch(() => {})
}
const handleIPaid = async () => {
setMarkedPaid(true)
fetch(`/api/pledges/${pledge.id}/mark-initiated`, { method: "POST" }).catch(() => {})
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "i_paid_clicked", pledgeId: pledge.id }),
}).catch(() => {})
}
const bd = pledge.bankDetails
if (markedPaid) {
return (
<div className="max-w-md mx-auto pt-8 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<Check className="h-10 w-10 text-success-green" />
</div>
<h1 className="text-2xl font-extrabold text-gray-900">Thank you!</h1>
<p className="text-muted-foreground">
We&apos;ll confirm once your payment of <span className="font-bold text-foreground">£{(amount / 100).toFixed(2)}</span> is received.
</p>
<Card className="text-left">
<CardContent className="pt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Event</span>
<span>{eventName}</span>
</div>
</CardContent>
</Card>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly.
</p>
</div>
)
}
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-trust-blue/10 mb-2">
<span className="text-3xl">🏦</span>
</div>
<h1 className="text-2xl font-extrabold text-gray-900">
Transfer £{(amount / 100).toFixed(2)}
</h1>
<p className="text-muted-foreground">
Use these details in your banking app
</p>
</div>
{/* Bank details card */}
{bd && (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Sort Code</p>
<p className="font-mono font-bold text-lg">{bd.sortCode}</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Account No</p>
<p className="font-mono font-bold text-lg">{bd.accountNo}</p>
</div>
</div>
<div className="text-sm">
<p className="text-muted-foreground text-xs uppercase tracking-wider">Account Name</p>
<p className="font-semibold">{bd.accountName}</p>
</div>
</CardContent>
</Card>
)}
{/* Reference - THE KEY */}
<div className="rounded-2xl border-2 border-trust-blue bg-trust-blue/5 p-6 text-center space-y-3">
<p className="text-xs font-semibold text-trust-blue uppercase tracking-wider">
Payment Reference use exactly:
</p>
<p className="text-3xl font-mono font-extrabold text-trust-blue tracking-wider">
{pledge.reference}
</p>
<Button
onClick={copyReference}
variant={copied ? "success" : "default"}
size="lg"
className="w-full"
>
{copied ? (
<>
<Check className="h-5 w-5 mr-2" /> Copied!
</>
) : (
<>
<Copy className="h-5 w-5 mr-2" /> Copy Reference
</>
)}
</Button>
</div>
{/* Suggestion */}
<div className="rounded-2xl bg-warm-amber/5 border border-warm-amber/20 p-4 text-center">
<p className="text-sm font-medium text-warm-amber">
<ExternalLink className="h-4 w-4 inline mr-1" />
Open your banking app now and search for &quot;new payment&quot;
</p>
</div>
{/* I've paid */}
<Button
size="xl"
variant="success"
className="w-full"
onClick={handleIPaid}
>
I&apos;ve Sent the Payment
</Button>
<p className="text-center text-xs text-muted-foreground">
Payments usually take 1-2 hours to arrive. We&apos;ll confirm once received.
</p>
</div>
)
}

View File

@@ -0,0 +1,305 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { CreditCard, Lock } from "lucide-react"
interface Props {
amount: number
eventName: string
eventId: string
qrSourceId: string | null
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 16)
return digits.replace(/(\d{4})(?=\d)/g, "$1 ")
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 4)
if (digits.length >= 3) return digits.slice(0, 2) + "/" + digits.slice(2)
return digits
}
function luhnCheck(num: string): boolean {
const digits = num.replace(/\D/g, "")
if (digits.length < 13) return false
let sum = 0
let alt = false
for (let i = digits.length - 1; i >= 0; i--) {
let n = parseInt(digits[i], 10)
if (alt) {
n *= 2
if (n > 9) n -= 9
}
sum += n
alt = !alt
}
return sum % 10 === 0
}
function getCardBrand(num: string): string {
const d = num.replace(/\D/g, "")
if (/^4/.test(d)) return "Visa"
if (/^5[1-5]/.test(d) || /^2[2-7]/.test(d)) return "Mastercard"
if (/^3[47]/.test(d)) return "Amex"
if (/^6(?:011|5)/.test(d)) return "Discover"
return ""
}
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId, onComplete }: Props) {
const [cardNumber, setCardNumber] = useState("")
const [expiry, setExpiry] = useState("")
const [cvc, setCvc] = useState("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [processing, setProcessing] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const expiryRef = useRef<HTMLInputElement>(null)
const cvcRef = useRef<HTMLInputElement>(null)
const pounds = (amount / 100).toFixed(2)
const brand = getCardBrand(cardNumber)
const validate = (): boolean => {
const errs: Record<string, string> = {}
const digits = cardNumber.replace(/\D/g, "")
if (!luhnCheck(digits)) errs.card = "Invalid card number"
if (digits.length < 13) errs.card = "Card number too short"
const expiryDigits = expiry.replace(/\D/g, "")
if (expiryDigits.length < 4) {
errs.expiry = "Invalid expiry"
} else {
const month = parseInt(expiryDigits.slice(0, 2), 10)
const year = parseInt("20" + expiryDigits.slice(2, 4), 10)
const now = new Date()
if (month < 1 || month > 12) errs.expiry = "Invalid month"
else if (year < now.getFullYear() || (year === now.getFullYear() && month < now.getMonth() + 1))
errs.expiry = "Card expired"
}
if (cvc.length < 3) errs.cvc = "Invalid CVC"
if (!name.trim()) errs.name = "Name required"
if (!email.includes("@")) errs.email = "Valid email required"
setErrors(errs)
return Object.keys(errs).length === 0
}
const handleSubmit = async () => {
if (!validate()) return
setProcessing(true)
// Try real Stripe Checkout first
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountPence: amount,
donorName: name,
donorEmail: email,
donorPhone: "",
giftAid,
eventId,
qrSourceId,
}),
})
const data = await res.json()
if (data.mode === "live" && data.checkoutUrl) {
// Redirect to Stripe Checkout
window.location.href = data.checkoutUrl
return
}
// Simulated mode — fall through to onComplete
} catch {
// Fall through to simulated
}
// Simulated fallback
await new Promise((r) => setTimeout(r, 1500))
onComplete({
donorName: name,
donorEmail: email,
donorPhone: "",
giftAid,
})
}
const handleCardNumberChange = (value: string) => {
const formatted = formatCardNumber(value)
setCardNumber(formatted)
// Auto-advance to expiry when complete
if (formatted.replace(/\s/g, "").length === 16) {
expiryRef.current?.focus()
}
}
const handleExpiryChange = (value: string) => {
const formatted = formatExpiry(value)
setExpiry(formatted)
if (formatted.length === 5) {
cvcRef.current?.focus()
}
}
const isReady = cardNumber.replace(/\D/g, "").length >= 13 && expiry.length === 5 && cvc.length >= 3 && name.trim() && email.includes("@")
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">Pay by Card</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Card form */}
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
{/* Card number */}
<div className="space-y-2">
<Label htmlFor="card-number">Card Number</Label>
<div className="relative">
<CreditCard className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
id="card-number"
placeholder="1234 5678 9012 3456"
value={cardNumber}
onChange={(e) => handleCardNumberChange(e.target.value)}
inputMode="numeric"
autoComplete="cc-number"
className={`pl-11 font-mono text-base ${errors.card ? "border-red-500" : ""}`}
/>
{brand && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-trust-blue bg-trust-blue/10 px-2 py-0.5 rounded-full">
{brand}
</span>
)}
</div>
{errors.card && <p className="text-xs text-red-500">{errors.card}</p>}
</div>
{/* Expiry + CVC row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="expiry">Expiry</Label>
<Input
id="expiry"
ref={expiryRef}
placeholder="MM/YY"
value={expiry}
onChange={(e) => handleExpiryChange(e.target.value)}
inputMode="numeric"
autoComplete="cc-exp"
maxLength={5}
className={`font-mono text-base ${errors.expiry ? "border-red-500" : ""}`}
/>
{errors.expiry && <p className="text-xs text-red-500">{errors.expiry}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="cvc">CVC</Label>
<Input
id="cvc"
ref={cvcRef}
placeholder="123"
value={cvc}
onChange={(e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 4))}
inputMode="numeric"
autoComplete="cc-csc"
maxLength={4}
className={`font-mono text-base ${errors.cvc ? "border-red-500" : ""}`}
/>
{errors.cvc && <p className="text-xs text-red-500">{errors.cvc}</p>}
</div>
</div>
{/* Cardholder name */}
<div className="space-y-2">
<Label htmlFor="card-name">Name on Card</Label>
<Input
id="card-name"
placeholder="J. Smith"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="cc-name"
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="card-email">Email</Label>
<Input
id="card-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost. You must be a UK taxpayer.
</p>
</div>
</label>
{/* Pay button */}
<Button
size="xl"
className="w-full"
disabled={!isReady || processing}
onClick={handleSubmit}
>
{processing ? (
<span className="flex items-center gap-2">
<span className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Processing...
</span>
) : (
<>
<Lock className="h-5 w-5 mr-2" />
Pay £{pounds}
</>
)}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Secured with 256-bit encryption</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
"use client"
import { Check } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
interface Props {
pledge: { id: string; reference: string }
amount: number
rail: string
eventName: string
}
export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
const railLabels: Record<string, string> = {
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card Payment",
fpx: "FPX Online Banking",
}
const currencySymbol = rail === "fpx" ? "RM" : "£"
const nextStepMessages: Record<string, string> = {
bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
gocardless: "Your Direct Debit mandate has been set up. The payment of " + currencySymbol + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless.",
card: "Your card payment is being processed. You'll receive a confirmation email shortly.",
fpx: "Your FPX payment has been received and is being verified. You'll receive a confirmation email once the payment is confirmed by your bank.",
}
return (
<div className="max-w-md mx-auto pt-8 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<Check className="h-10 w-10 text-success-green" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
{rail === "fpx" ? "Payment Successful!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous {rail === "fpx" ? "donation" : "pledge"} to{" "}
<span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">{currencySymbol}{(amount / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Payment Method</span>
<span>{railLabels[rail] || rail}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
{rail === "gocardless" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Collection</span>
<span className="text-sm">3-5 working days</span>
</div>
)}
{rail === "fpx" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className="text-success-green font-semibold">Paid </span>
</div>
)}
</CardContent>
</Card>
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
<p className="text-sm font-medium text-trust-blue">What happens next?</p>
<p className="text-sm text-muted-foreground">
{nextStepMessages[rail] || nextStepMessages.bank}
</p>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly. Ref: {pledge.reference}
</p>
</div>
)
}

View File

@@ -0,0 +1,365 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Lock, ShieldCheck, Landmark } from "lucide-react"
interface Props {
amount: number
eventName: string
organizationName: string
eventId: string
qrSourceId: string | null
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
function formatSortCode(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 6)
if (digits.length > 4) return digits.slice(0, 2) + "-" + digits.slice(2, 4) + "-" + digits.slice(4)
if (digits.length > 2) return digits.slice(0, 2) + "-" + digits.slice(2)
return digits
}
type Phase = "form" | "reviewing" | "processing"
export function DirectDebitStep({ amount, eventName, organizationName, eventId, qrSourceId, onComplete }: Props) {
const [phase, setPhase] = useState<Phase>("form")
const [accountName, setAccountName] = useState("")
const [email, setEmail] = useState("")
const [sortCode, setSortCode] = useState("")
const [accountNumber, setAccountNumber] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [mandateAgreed, setMandateAgreed] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const accountNumberRef = useRef<HTMLInputElement>(null)
const pounds = (amount / 100).toFixed(2)
const validate = (): boolean => {
const errs: Record<string, string> = {}
if (!accountName.trim()) errs.accountName = "Account holder name required"
if (!email.includes("@")) errs.email = "Valid email required"
const scDigits = sortCode.replace(/\D/g, "")
if (scDigits.length !== 6) errs.sortCode = "Sort code must be 6 digits"
const anDigits = accountNumber.replace(/\D/g, "")
if (anDigits.length < 7 || anDigits.length > 8) errs.accountNumber = "Account number must be 7-8 digits"
if (!mandateAgreed) errs.mandate = "You must agree to the Direct Debit mandate"
setErrors(errs)
return Object.keys(errs).length === 0
}
const handleSortCodeChange = (value: string) => {
const formatted = formatSortCode(value)
setSortCode(formatted)
// Auto-advance when complete
if (formatted.replace(/\D/g, "").length === 6) {
accountNumberRef.current?.focus()
}
}
const handleReview = () => {
if (!validate()) return
setPhase("reviewing")
}
const handleConfirm = async () => {
setPhase("processing")
// Try real GoCardless flow first
try {
const res = await fetch("/api/gocardless/create-flow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountPence: amount,
donorName: accountName,
donorEmail: email,
donorPhone: "",
giftAid,
eventId,
qrSourceId,
}),
})
const data = await res.json()
if (data.mode === "live" && data.redirectUrl) {
// Redirect to GoCardless hosted page
window.location.href = data.redirectUrl
return
}
// Simulated mode — fall through to onComplete
} catch {
// Fall through to simulated
}
// Simulated fallback
await new Promise((r) => setTimeout(r, 1000))
onComplete({
donorName: accountName,
donorEmail: email,
donorPhone: "",
giftAid,
})
}
// Processing phase
if (phase === "processing") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-trust-blue/10">
<div className="h-10 w-10 border-4 border-trust-blue border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Setting Up Direct Debit
</h1>
<p className="text-muted-foreground">
Creating your mandate with GoCardless...
</p>
</div>
<p className="text-xs text-muted-foreground">
This may take a few seconds.
</p>
</div>
)
}
// Review phase
if (phase === "reviewing") {
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-trust-blue/10 mb-2">
<ShieldCheck className="h-8 w-8 text-trust-blue" />
</div>
<h1 className="text-2xl font-extrabold text-gray-900">
Confirm Direct Debit
</h1>
<p className="text-muted-foreground">
Please review your mandate details
</p>
</div>
{/* Summary card */}
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Account Holder</p>
<p className="font-semibold">{accountName}</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Email</p>
<p className="font-semibold truncate">{email}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Sort Code</p>
<p className="font-mono font-bold text-lg">{sortCode}</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Account Number</p>
<p className="font-mono font-bold text-lg">{accountNumber.slice(-4)}</p>
</div>
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">Amount</p>
<p className="font-bold text-xl">£{pounds}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Payee</p>
<p className="font-semibold">{organizationName}</p>
</div>
</div>
</div>
</div>
{/* Guarantee box */}
<div className="rounded-2xl bg-emerald-50 border border-emerald-200 p-4 space-y-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-600" />
<p className="text-sm font-semibold text-emerald-800">Direct Debit Guarantee</p>
</div>
<p className="text-xs text-emerald-700 leading-relaxed">
This Guarantee is offered by all banks and building societies that accept instructions to pay Direct Debits.
If there are any changes to the amount, date, or frequency of your Direct Debit, you will be notified in advance.
If an error is made in the payment, you are entitled to a full and immediate refund from your bank.
You can cancel a Direct Debit at any time by contacting your bank.
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" size="xl" className="flex-1" onClick={() => setPhase("form")}>
Edit
</Button>
<Button size="xl" className="flex-1" onClick={handleConfirm}>
<Lock className="h-5 w-5 mr-2" />
Confirm Mandate
</Button>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Processed securely by GoCardless</span>
</div>
</div>
)
}
// Form phase (default)
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Set Up Direct Debit
</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Info banner */}
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 flex items-start gap-3">
<Landmark className="h-5 w-5 text-trust-blue flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-trust-blue">How it works</p>
<p className="text-xs text-muted-foreground mt-1">
We&apos;ll set up a Direct Debit mandate via GoCardless. The payment will be collected automatically in 3-5 working days. You can cancel anytime.
</p>
</div>
</div>
{/* Bank details form */}
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="dd-name">Account Holder Name</Label>
<Input
id="dd-name"
placeholder="As shown on your bank statement"
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
autoComplete="name"
className={errors.accountName ? "border-red-500" : ""}
/>
{errors.accountName && <p className="text-xs text-red-500">{errors.accountName}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="dd-email">Email</Label>
<Input
id="dd-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="dd-sort">Sort Code</Label>
<Input
id="dd-sort"
placeholder="00-00-00"
value={sortCode}
onChange={(e) => handleSortCodeChange(e.target.value)}
inputMode="numeric"
maxLength={8}
className={`font-mono text-base ${errors.sortCode ? "border-red-500" : ""}`}
/>
{errors.sortCode && <p className="text-xs text-red-500">{errors.sortCode}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="dd-account">Account Number</Label>
<Input
id="dd-account"
ref={accountNumberRef}
placeholder="12345678"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value.replace(/\D/g, "").slice(0, 8))}
inputMode="numeric"
maxLength={8}
className={`font-mono text-base ${errors.accountNumber ? "border-red-500" : ""}`}
/>
{errors.accountNumber && <p className="text-xs text-red-500">{errors.accountNumber}</p>}
</div>
</div>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost. You must be a UK taxpayer.
</p>
</div>
</label>
{/* Direct Debit mandate agreement */}
<label className={`flex items-start gap-3 rounded-2xl border-2 p-4 cursor-pointer transition-colors ${
errors.mandate ? "border-red-300 bg-red-50" : "border-gray-200 bg-white hover:border-trust-blue/50"
}`}>
<input
type="checkbox"
checked={mandateAgreed}
onChange={(e) => setMandateAgreed(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">I authorise this Direct Debit</span>
<p className="text-sm text-muted-foreground mt-0.5">
I confirm that I am the account holder and authorise <span className="font-medium">{organizationName}</span> to collect <span className="font-medium">£{pounds}</span> from my account via Direct Debit, subject to the Direct Debit Guarantee.
</p>
</div>
</label>
{errors.mandate && <p className="text-xs text-red-500 -mt-4 ml-1">{errors.mandate}</p>}
<Button
size="xl"
className="w-full"
onClick={handleReview}
>
Review Mandate
</Button>
{/* DD Guarantee mini */}
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-3 flex items-start gap-2">
<ShieldCheck className="h-4 w-4 text-emerald-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-emerald-700">
Protected by the <span className="font-semibold">Direct Debit Guarantee</span>. You can cancel anytime by contacting your bank. Full refund if any errors occur.
</p>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Processed securely by GoCardless</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,329 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Lock, Search, CheckCircle2 } from "lucide-react"
interface Props {
amount: number
eventName: string
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
interface Bank {
code: string
name: string
shortName: string
online: boolean
}
const FPX_BANKS: Bank[] = [
{ code: "MBB", name: "Maybank2u", shortName: "Maybank", online: true },
{ code: "CIMB", name: "CIMB Clicks", shortName: "CIMB", online: true },
{ code: "PBB", name: "PBe Bank", shortName: "Public Bank", online: true },
{ code: "RHB", name: "RHB Now", shortName: "RHB", online: true },
{ code: "HLB", name: "Hong Leong Connect", shortName: "Hong Leong", online: true },
{ code: "AMBB", name: "AmOnline", shortName: "AmBank", online: true },
{ code: "BIMB", name: "Bank Islam GO", shortName: "Bank Islam", online: true },
{ code: "BKRM", name: "i-Rakyat", shortName: "Bank Rakyat", online: true },
{ code: "BSN", name: "myBSN", shortName: "BSN", online: true },
{ code: "OCBC", name: "OCBC Online", shortName: "OCBC", online: true },
{ code: "UOB", name: "UOB Personal", shortName: "UOB", online: true },
{ code: "ABB", name: "Affin Online", shortName: "Affin Bank", online: true },
{ code: "ABMB", name: "Alliance Online", shortName: "Alliance Bank", online: true },
{ code: "BMMB", name: "Bank Muamalat", shortName: "Muamalat", online: true },
{ code: "SCB", name: "SC Online", shortName: "Standard Chartered", online: true },
{ code: "HSBC", name: "HSBC Online", shortName: "HSBC", online: true },
{ code: "AGR", name: "AGRONet", shortName: "Agrobank", online: true },
{ code: "KFH", name: "KFH Online", shortName: "KFH", online: true },
]
const BANK_COLORS: Record<string, string> = {
MBB: "bg-yellow-500",
CIMB: "bg-red-600",
PBB: "bg-pink-700",
RHB: "bg-blue-800",
HLB: "bg-blue-600",
AMBB: "bg-green-700",
BIMB: "bg-emerald-700",
BKRM: "bg-blue-900",
BSN: "bg-orange-600",
OCBC: "bg-red-700",
UOB: "bg-blue-700",
ABB: "bg-amber-700",
ABMB: "bg-teal-700",
BMMB: "bg-green-800",
SCB: "bg-green-600",
HSBC: "bg-red-500",
AGR: "bg-green-900",
KFH: "bg-yellow-700",
}
type Phase = "select" | "identity" | "redirecting" | "processing"
export function FpxPaymentStep({ amount, eventName, onComplete }: Props) {
const [phase, setPhase] = useState<Phase>("select")
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
const [search, setSearch] = useState("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const ringgit = (amount / 100).toFixed(2)
const filteredBanks = search
? FPX_BANKS.filter(
(b) =>
b.name.toLowerCase().includes(search.toLowerCase()) ||
b.shortName.toLowerCase().includes(search.toLowerCase()) ||
b.code.toLowerCase().includes(search.toLowerCase())
)
: FPX_BANKS
const handleBankSelect = (bank: Bank) => {
setSelectedBank(bank)
}
const handleContinueToIdentity = () => {
if (!selectedBank) return
setPhase("identity")
}
const handleSubmit = async () => {
const errs: Record<string, string> = {}
if (!email.includes("@")) errs.email = "Valid email required"
setErrors(errs)
if (Object.keys(errs).length > 0) return
setPhase("redirecting")
// Simulate FPX redirect flow
await new Promise((r) => setTimeout(r, 2000))
setPhase("processing")
await new Promise((r) => setTimeout(r, 1500))
onComplete({
donorName: name,
donorEmail: email,
donorPhone: phone,
giftAid: false, // Gift Aid not applicable for MYR
})
}
// Redirecting phase
if (phase === "redirecting") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-trust-blue/10">
<div className="h-10 w-10 border-4 border-trust-blue border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Redirecting to {selectedBank?.name}
</h1>
<p className="text-muted-foreground">
You&apos;ll be taken to your bank&apos;s secure login page to authorize the payment of <span className="font-bold text-foreground">RM{ringgit}</span>
</p>
</div>
<div className="rounded-2xl bg-gray-50 border p-4">
<div className="flex items-center justify-center gap-3">
<div className={`w-10 h-10 rounded-lg ${BANK_COLORS[selectedBank?.code || ""] || "bg-gray-500"} flex items-center justify-center`}>
<span className="text-white font-bold text-xs">{selectedBank?.code}</span>
</div>
<span className="font-semibold">{selectedBank?.name}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Do not close this window. You will be redirected back automatically.
</p>
</div>
)
}
// Processing phase
if (phase === "processing") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<div className="h-10 w-10 border-4 border-success-green border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Processing Payment
</h1>
<p className="text-muted-foreground">
Verifying your payment with {selectedBank?.shortName}...
</p>
</div>
</div>
)
}
// Identity phase
if (phase === "identity") {
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">Your Details</h1>
<p className="text-muted-foreground">
Before we redirect you to <span className="font-semibold text-foreground">{selectedBank?.name}</span>
</p>
</div>
{/* Selected bank indicator */}
<div className="rounded-2xl border-2 border-trust-blue/20 bg-trust-blue/5 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${BANK_COLORS[selectedBank?.code || ""] || "bg-gray-500"} flex items-center justify-center`}>
<span className="text-white font-bold text-xs">{selectedBank?.code}</span>
</div>
<div>
<p className="font-semibold text-sm">{selectedBank?.name}</p>
<p className="text-xs text-muted-foreground">FPX Online Banking</p>
</div>
</div>
<div className="text-right">
<p className="font-bold text-lg">RM{ringgit}</p>
<p className="text-xs text-muted-foreground">{eventName}</p>
</div>
</div>
</div>
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="fpx-name">Full Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="fpx-name"
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fpx-email">Email</Label>
<Input
id="fpx-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
<p className="text-xs text-muted-foreground">We&apos;ll send your receipt here</p>
</div>
<div className="space-y-2">
<Label htmlFor="fpx-phone">Phone <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="fpx-phone"
type="tel"
placeholder="+60 12-345 6789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</div>
</div>
<Button size="xl" className="w-full" onClick={handleSubmit}>
<Lock className="h-5 w-5 mr-2" />
Pay RM{ringgit} via {selectedBank?.shortName}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Secured by FPX Bank Negara Malaysia</span>
</div>
</div>
)
}
// Bank selection phase (default)
return (
<div className="max-w-md mx-auto pt-4 space-y-5">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
FPX Online Banking
</h1>
<p className="text-lg text-muted-foreground">
Pay <span className="font-bold text-foreground">RM{ringgit}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search your bank..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Bank list */}
<div className="grid grid-cols-2 gap-2 max-h-[400px] overflow-y-auto pr-1">
{filteredBanks.map((bank) => (
<button
key={bank.code}
onClick={() => handleBankSelect(bank)}
className={`
text-left rounded-xl border-2 p-3 transition-all active:scale-[0.98]
${selectedBank?.code === bank.code
? "border-trust-blue bg-trust-blue/5 shadow-md shadow-trust-blue/10"
: "border-gray-200 bg-white hover:border-gray-300"
}
`}
>
<div className="flex items-center gap-2.5">
<div className={`w-9 h-9 rounded-lg ${BANK_COLORS[bank.code] || "bg-gray-500"} flex items-center justify-center flex-shrink-0`}>
<span className="text-white font-bold text-[10px] leading-none">{bank.code}</span>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-xs text-gray-900 truncate">{bank.shortName}</p>
<p className="text-[10px] text-muted-foreground truncate">{bank.name}</p>
</div>
{selectedBank?.code === bank.code && (
<CheckCircle2 className="h-4 w-4 text-trust-blue flex-shrink-0" />
)}
</div>
</button>
))}
</div>
{filteredBanks.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-4">No banks found matching &quot;{search}&quot;</p>
)}
{/* Continue */}
<Button
size="xl"
className="w-full"
disabled={!selectedBank}
onClick={handleContinueToIdentity}
>
Continue with {selectedBank?.shortName || "selected bank"}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Powered by FPX regulated by Bank Negara Malaysia</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
interface Props {
onSubmit: (data: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
export function IdentityStep({ onSubmit }: Props) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [giftAid, setGiftAid] = useState(false)
const [submitting, setSubmitting] = useState(false)
const hasContact = email.includes("@") || phone.length >= 10
const isValid = hasContact
const handleSubmit = async () => {
if (!isValid) return
setSubmitting(true)
try {
await onSubmit({ donorName: name, donorEmail: email, donorPhone: phone, giftAid })
} catch {
setSubmitting(false)
}
}
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Almost there!
</h1>
<p className="text-muted-foreground">
We need a way to send you payment details
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="name"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
/>
</div>
<div className="relative flex items-center">
<div className="flex-grow border-t border-gray-200" />
<span className="flex-shrink mx-3 text-xs text-muted-foreground">or</span>
<div className="flex-grow border-t border-gray-200" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
placeholder="07700 900000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost to you. You must be a UK taxpayer.
</p>
</div>
</label>
</div>
<Button
size="xl"
className="w-full"
disabled={!isValid || submitting}
onClick={handleSubmit}
>
{submitting ? "Submitting..." : "Complete Pledge ✓"}
</Button>
<p className="text-center text-xs text-muted-foreground">
We&apos;ll only use this to send payment details and confirm receipt.
</p>
</div>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { Building2, CreditCard, Landmark, Globe } from "lucide-react"
interface Props {
onSelect: (rail: "bank" | "gocardless" | "card" | "fpx") => void
amount: number
}
export function PaymentStep({ onSelect, amount }: Props) {
const pounds = (amount / 100).toFixed(2)
const options = [
{
id: "bank" as const,
icon: Building2,
title: "Bank Transfer",
subtitle: "Zero fees — 100% goes to charity",
tag: "Recommended",
tagColor: "bg-success-green text-white",
detail: "Use your banking app to transfer directly",
},
{
id: "gocardless" as const,
icon: Landmark,
title: "Direct Debit",
subtitle: "Automatic collection — set and forget",
tag: "Low fees",
tagColor: "bg-trust-blue/10 text-trust-blue",
detail: "We'll collect via GoCardless",
},
{
id: "card" as const,
icon: CreditCard,
title: "Card Payment via Stripe",
subtitle: "Pay now by Visa, Mastercard, Amex",
tag: "Stripe",
tagColor: "bg-purple-100 text-purple-700",
detail: "Secure payment powered by Stripe",
},
{
id: "fpx" as const,
icon: Globe,
title: "FPX Online Banking",
subtitle: "Pay via Malaysian bank account",
tag: "Malaysia",
tagColor: "bg-amber-500/10 text-amber-700",
detail: "Instant payment from 18 Malaysian banks",
},
]
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
How would you like to pay?
</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>
</p>
</div>
<div className="space-y-3">
{options.map((opt) => (
<button
key={opt.id}
onClick={() => onSelect(opt.id)}
className="w-full text-left tap-target rounded-2xl border-2 border-gray-200 bg-white p-5 hover:border-trust-blue/50 active:scale-[0.99] transition-all group"
>
<div className="flex items-start gap-4">
<div className="rounded-xl bg-trust-blue/5 p-3 group-hover:bg-trust-blue/10 transition-colors">
<opt.icon className="h-6 w-6 text-trust-blue" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">{opt.title}</span>
{opt.tag && (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${opt.tagColor}`}>
{opt.tag}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">{opt.subtitle}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{opt.detail}</p>
</div>
<div className="text-muted-foreground/40 group-hover:text-trust-blue transition-colors text-xl">
</div>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Check, X, Loader2 } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface PledgeInfo {
reference: string
amountPence: number
rail: string
donorName: string | null
eventName: string
status: string
}
function SuccessContent() {
const searchParams = useSearchParams()
const pledgeId = searchParams.get("pledge_id")
const rail = searchParams.get("rail") || "card"
const cancelled = searchParams.get("cancelled") === "true"
const [pledge, setPledge] = useState<PledgeInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!pledgeId) { setLoading(false); return }
fetch(`/api/pledges/${pledgeId}`)
.then((r) => r.json())
.then((data) => { if (data.reference) setPledge(data) })
.catch(() => {})
.finally(() => setLoading(false))
}, [pledgeId])
if (loading) {
return (
<div className="text-center space-y-4">
<Loader2 className="h-10 w-10 text-trust-blue animate-spin mx-auto" />
<p className="text-muted-foreground">Confirming your payment...</p>
</div>
)
}
if (cancelled) {
return (
<div className="max-w-md mx-auto text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100">
<X className="h-10 w-10 text-amber-600" />
</div>
<h1 className="text-2xl font-extrabold text-gray-900">Payment Cancelled</h1>
<p className="text-muted-foreground">
Your payment was not completed. Your pledge has been saved you can return to complete it anytime.
</p>
{pledge && (
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">£{(pledge.amountPence / 100).toFixed(2)}</span>
</div>
</CardContent>
</Card>
)}
<Link href="/">
<Button variant="outline" size="lg">Return Home</Button>
</Link>
</div>
)
}
const railLabels: Record<string, string> = {
card: "Card Payment",
gocardless: "Direct Debit",
fpx: "FPX Online Banking",
bank: "Bank Transfer",
}
const nextStepMessages: Record<string, string> = {
card: "Your card payment has been processed. You'll receive a confirmation email shortly.",
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
fpx: "Your FPX payment has been received and verified.",
bank: "Please complete the bank transfer using the reference provided.",
}
return (
<div className="max-w-md mx-auto text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<Check className="h-10 w-10 text-success-green" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
{rail === "gocardless" ? "Mandate Set Up!" : "Payment Successful!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous donation
{pledge?.eventName && <> to <span className="font-semibold text-foreground">{pledge.eventName}</span></>}
</p>
</div>
{pledge && (
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">£{(pledge.amountPence / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Method</span>
<span>{railLabels[rail] || rail}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Reference</span>
<span className="font-mono font-bold">{pledge.reference}</span>
</div>
{pledge.donorName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Donor</span>
<span>{pledge.donorName}</span>
</div>
)}
</CardContent>
</Card>
)}
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
<p className="text-sm font-medium text-trust-blue">What happens next?</p>
<p className="text-sm text-muted-foreground">{nextStepMessages[rail] || nextStepMessages.card}</p>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly.{pledge && <> Ref: {pledge.reference}</>}
</p>
</div>
)
}
export default function SuccessPage() {
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">
<Suspense fallback={
<div className="text-center space-y-4">
<Loader2 className="h-10 w-10 text-trust-blue animate-spin mx-auto" />
<p className="text-muted-foreground">Loading...</p>
</div>
}>
<SuccessContent />
</Suspense>
</div>
)
}