feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user