Design system changes (per brand guide): - ZERO rounded-2xl/3xl remaining (was 131 instances) - ZERO bg-gradient remaining (was 25) — all solid colors - ZERO colored shadows (shadow-trust-blue, etc) — flat, no glow - ZERO backdrop-blur/glass effects — solid backgrounds - ZERO emoji in logo marks — square P logomark everywhere - ZERO decorative scale animations (group-hover:scale-105, etc) Tailwind config: - Added brand color names: midnight, promise-blue, generosity-gold, fulfilled-green, alert-red, paper - Kept legacy aliases (trust-blue, etc) for backwards compat - --radius: 0.75rem → 0.5rem (tighter corners) CSS: - Removed glass, glass-dark, card-hover, pulse-ring, bounce-gentle, confetti-fall, scale-in animations - Kept only purposeful animations: fadeUp, fadeIn, slideDown, shimmer, counter-roll - --primary tuned to match Promise Blue exactly Components: - Button: removed all colored shadows, added 'blue' variant, removed rounded from sizes - All UI components: rounded-xl/2xl → rounded-lg Pages updated (41 files): - Dashboard layout: solid header (no blur), border-l-2 active indicator, midnight logo mark - Login/Signup: paper bg (no gradient), midnight logo mark, no emoji - Pledge flow: solid color icons, no gradient progress bars - All dashboard pages: flat, sharp, editorial
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
"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-16 h-16 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-14 h-14 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-lg 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-lg 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-lg 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-lg 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-lg 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-lg 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-lg 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>
|
|
)
|
|
}
|