Files
calvana/pledge-now-pay-later/src/app/p/[token]/steps/direct-debit-step.tsx
Omair Saleh fc80399092 brand identity overhaul: match BRAND-IDENTITY.md across all pages
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
2026-03-03 20:13:22 +08:00

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