feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
12
pledge-now-pay-later/src/app/p/[token]/loading.tsx
Normal file
12
pledge-now-pay-later/src/app/p/[token]/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
pledge-now-pay-later/src/app/p/[token]/page.tsx
Normal file
210
pledge-now-pay-later/src/app/p/[token]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
Normal file
97
pledge-now-pay-later/src/app/p/[token]/steps/amount-step.tsx
Normal 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't be charged now. Choose how to pay next.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'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 "new payment"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* I've paid */}
|
||||
<Button
|
||||
size="xl"
|
||||
variant="success"
|
||||
className="w-full"
|
||||
onClick={handleIPaid}
|
||||
>
|
||||
I've Sent the Payment ✓
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Payments usually take 1-2 hours to arrive. We'll confirm once received.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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'll be taken to your bank'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'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 "{search}"</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>
|
||||
)
|
||||
}
|
||||
123
pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
Normal file
123
pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
Normal 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'll only use this to send payment details and confirm receipt.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
152
pledge-now-pay-later/src/app/p/success/page.tsx
Normal file
152
pledge-now-pay-later/src/app/p/success/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user