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