GIFT AID (HMRC compliance):
- Exact HMRC model declaration text displayed and recorded
- Home address (line 1 + postcode) collected when Gift Aid is ticked
- giftAidAt timestamp recorded separately from the boolean
- Declaration text, donor name, timestamp stored in consentMeta JSON
EMAIL + WHATSAPP (GDPR/PECR compliance):
- Separate, granular opt-in checkboxes (not bundled, not pre-ticked)
- Each consent records: exact text shown, timestamp, consent version
- Consent checkboxes only appear when relevant contact info is provided
- Cron reminders gated on consent — no sends without opt-in
- Pledge creation WhatsApp receipt gated on whatsappOptIn
AUDIT TRAIL (consentMeta JSON on every pledge):
- giftAid: {declared, declarationText, declaredAt}
- email: {granted, consentText, grantedAt}
- whatsapp: {granted, consentText, grantedAt}
- IP address captured server-side from x-forwarded-for
- User agent captured client-side
- consentVersion field for tracking wording changes
EXPORTS:
- CRM CSV now includes: donor_address, donor_postcode, gift_aid_declared_at,
is_zakat, email_opt_in, whatsapp_opt_in
- Gift Aid export has full HMRC-required fields
Schema: 6 new columns on Pledge (donorAddressLine1, donorPostcode,
giftAidAt, emailOptIn, whatsappOptIn, consentMeta)
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useEffect } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Gift, Shield, Sparkles, Phone, Mail, MapPin, ChevronDown } from "lucide-react"
|
|
|
|
// --- HMRC Gift Aid declaration (exact model wording) ---
|
|
const GIFT_AID_DECLARATION =
|
|
"I am a UK taxpayer and understand that if I pay less Income Tax and/or Capital Gains Tax in the current tax year than the amount of Gift Aid claimed on all my donations in that tax year it is my responsibility to pay any difference."
|
|
|
|
const EMAIL_CONSENT_TEXT =
|
|
"I agree to receive email updates about this pledge, including payment reminders and receipts."
|
|
|
|
const WHATSAPP_CONSENT_TEXT =
|
|
"I agree to receive WhatsApp messages about this pledge, including payment reminders and receipts. Reply STOP to opt out."
|
|
|
|
const CONSENT_VERSION = "v1"
|
|
|
|
interface ConsentData {
|
|
donorName: string
|
|
donorEmail: string
|
|
donorPhone: string
|
|
donorAddressLine1: string
|
|
donorPostcode: string
|
|
giftAid: boolean
|
|
isZakat: boolean
|
|
emailOptIn: boolean
|
|
whatsappOptIn: boolean
|
|
consentMeta: {
|
|
giftAid?: { declared: boolean; declarationText: string; declaredAt: string }
|
|
email?: { granted: boolean; consentText: string; grantedAt: string }
|
|
whatsapp?: { granted: boolean; consentText: string; grantedAt: string }
|
|
consentVersion: string
|
|
}
|
|
}
|
|
|
|
interface Props {
|
|
onSubmit: (data: ConsentData) => void
|
|
amount: number
|
|
zakatEligible?: boolean
|
|
orgName?: string
|
|
}
|
|
|
|
export function IdentityStep({ onSubmit, amount, zakatEligible, orgName }: Props) {
|
|
const [name, setName] = useState("")
|
|
const [email, setEmail] = useState("")
|
|
const [phone, setPhone] = useState("")
|
|
const [addressLine1, setAddressLine1] = useState("")
|
|
const [postcode, setPostcode] = useState("")
|
|
const [giftAid, setGiftAid] = useState(false)
|
|
const [isZakat, setIsZakat] = useState(false)
|
|
const [emailOptIn, setEmailOptIn] = useState(false)
|
|
const [whatsappOptIn, setWhatsappOptIn] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [showGiftAidAddress, setShowGiftAidAddress] = useState(false)
|
|
const nameRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => { nameRef.current?.focus() }, [])
|
|
|
|
// When Gift Aid is toggled on, show address fields
|
|
useEffect(() => {
|
|
if (giftAid && !showGiftAidAddress) setShowGiftAidAddress(true)
|
|
}, [giftAid, showGiftAidAddress])
|
|
|
|
// When Gift Aid is toggled off, clear address
|
|
useEffect(() => {
|
|
if (!giftAid) {
|
|
setShowGiftAidAddress(false)
|
|
setAddressLine1("")
|
|
setPostcode("")
|
|
}
|
|
}, [giftAid])
|
|
|
|
const hasEmail = email.includes("@")
|
|
const hasPhone = phone.length >= 10
|
|
const hasContact = hasEmail || hasPhone
|
|
// Gift Aid requires: name + address + postcode + UK taxpayer declaration
|
|
const giftAidValid = !giftAid || (name.length > 0 && addressLine1.length > 0 && postcode.length >= 5)
|
|
const isValid = hasContact && giftAidValid
|
|
|
|
const giftAidBonus = Math.round(amount * 0.25)
|
|
const totalWithAid = amount + giftAidBonus
|
|
|
|
const handleSubmit = async () => {
|
|
if (!isValid) return
|
|
setSubmitting(true)
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
// Build immutable consent audit record
|
|
const consentMeta: ConsentData["consentMeta"] = {
|
|
consentVersion: CONSENT_VERSION,
|
|
}
|
|
|
|
if (giftAid) {
|
|
consentMeta.giftAid = {
|
|
declared: true,
|
|
declarationText: GIFT_AID_DECLARATION,
|
|
declaredAt: now,
|
|
}
|
|
}
|
|
|
|
if (emailOptIn && hasEmail) {
|
|
consentMeta.email = {
|
|
granted: true,
|
|
consentText: EMAIL_CONSENT_TEXT,
|
|
grantedAt: now,
|
|
}
|
|
}
|
|
|
|
if (whatsappOptIn && hasPhone) {
|
|
consentMeta.whatsapp = {
|
|
granted: true,
|
|
consentText: WHATSAPP_CONSENT_TEXT,
|
|
grantedAt: now,
|
|
}
|
|
}
|
|
|
|
try {
|
|
await onSubmit({
|
|
donorName: name,
|
|
donorEmail: email,
|
|
donorPhone: phone,
|
|
donorAddressLine1: giftAid ? addressLine1 : "",
|
|
donorPostcode: giftAid ? postcode : "",
|
|
giftAid,
|
|
isZakat: zakatEligible ? isZakat : false,
|
|
emailOptIn: emailOptIn && hasEmail,
|
|
whatsappOptIn: whatsappOptIn && hasPhone,
|
|
consentMeta,
|
|
})
|
|
} catch {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-md mx-auto pt-2 space-y-5 animate-fade-up">
|
|
<div className="text-center space-y-2">
|
|
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
|
|
Almost there!
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm">
|
|
We just need your details to process this pledge
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Contact Details ── */}
|
|
<div className="space-y-3">
|
|
{/* Name */}
|
|
<div className="relative">
|
|
<input
|
|
ref={nameRef}
|
|
type="text"
|
|
placeholder="Full name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
autoComplete="name"
|
|
className="w-full h-14 px-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
|
|
/>
|
|
{name && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-green animate-scale-in">✓</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div className="relative">
|
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" />
|
|
<input
|
|
type="email"
|
|
placeholder="Email address"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
autoComplete="email"
|
|
inputMode="email"
|
|
className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
|
|
/>
|
|
{hasEmail && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-green animate-scale-in">✓</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phone */}
|
|
<div>
|
|
<div className="relative">
|
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" />
|
|
<input
|
|
type="tel"
|
|
placeholder="Mobile number (for WhatsApp reminders)"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
autoComplete="tel"
|
|
inputMode="tel"
|
|
className="w-full h-14 pl-12 pr-4 rounded-2xl border-2 border-gray-200 bg-white text-base font-medium placeholder:text-gray-300 focus:border-trust-blue focus:ring-4 focus:ring-trust-blue/10 outline-none transition-all"
|
|
/>
|
|
{hasPhone && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-green animate-scale-in">✓</div>
|
|
)}
|
|
</div>
|
|
{!hasEmail && !hasPhone && (
|
|
<p className="text-xs text-muted-foreground mt-1.5 ml-1">
|
|
Enter at least an email or mobile number
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Zakat ── */}
|
|
{zakatEligible && (
|
|
<ConsentCheckbox
|
|
checked={isZakat}
|
|
onChange={setIsZakat}
|
|
icon="🌙"
|
|
label="This is Zakat"
|
|
description="Mark this pledge as Zakat (obligatory charity). It will be tracked separately."
|
|
/>
|
|
)}
|
|
|
|
{/* ── Gift Aid (HMRC) ── */}
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={() => setGiftAid(!giftAid)}
|
|
className={`w-full text-left rounded-2xl border-2 p-5 transition-all duration-300 ${
|
|
giftAid
|
|
? "border-success-green bg-gradient-to-br from-success-green/5 to-emerald-50 shadow-lg shadow-success-green/10"
|
|
: "border-gray-200 bg-white hover:border-success-green/40"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className={`rounded-xl p-3 transition-all ${giftAid ? "bg-success-green shadow-lg shadow-success-green/30" : "bg-gray-100"}`}>
|
|
<Gift className={`h-6 w-6 transition-colors ${giftAid ? "text-white" : "text-gray-400"}`} />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-bold text-gray-900">
|
|
{giftAid ? "Gift Aid added!" : "Add Gift Aid"}
|
|
</span>
|
|
{giftAid ? (
|
|
<span className="text-xs font-bold px-2.5 py-0.5 rounded-full bg-success-green text-white animate-scale-in flex items-center gap-1">
|
|
<Sparkles className="h-3 w-3" /> +£{(giftAidBonus / 100).toFixed(0)} free
|
|
</span>
|
|
) : (
|
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-success-green/10 text-success-green">
|
|
+25%
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{giftAid ? (
|
|
<div className="mt-2 space-y-2 animate-fade-in">
|
|
{/* Live math */}
|
|
<div className="flex items-center justify-between bg-white rounded-xl p-3 border border-success-green/20">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Your pledge</p>
|
|
<p className="font-bold">£{(amount / 100).toFixed(0)}</p>
|
|
</div>
|
|
<div className="text-success-green font-bold text-xl">+</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">HMRC adds</p>
|
|
<p className="font-bold text-success-green">£{(giftAidBonus / 100).toFixed(0)}</p>
|
|
</div>
|
|
<div className="text-success-green font-bold text-xl">=</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Charity gets</p>
|
|
<p className="font-black text-success-green text-lg">£{(totalWithAid / 100).toFixed(0)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Boost your £{(amount / 100).toFixed(0)} to{" "}
|
|
<span className="font-bold text-success-green">£{(totalWithAid / 100).toFixed(0)}</span> at zero cost. HMRC adds 25%.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Gift Aid address + declaration (shown when Gift Aid is on) */}
|
|
{giftAid && showGiftAidAddress && (
|
|
<div className="rounded-2xl border-2 border-success-green/20 bg-white p-4 space-y-3 animate-fade-in">
|
|
{/* HMRC requires home address */}
|
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<MapPin className="h-3.5 w-3.5" />
|
|
<span>Home address (required by HMRC for Gift Aid)</span>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="House number and street"
|
|
value={addressLine1}
|
|
onChange={(e) => setAddressLine1(e.target.value)}
|
|
autoComplete="address-line1"
|
|
className="w-full h-12 px-4 rounded-xl border-2 border-gray-200 bg-white text-sm font-medium placeholder:text-gray-300 focus:border-success-green focus:ring-4 focus:ring-success-green/10 outline-none transition-all"
|
|
/>
|
|
{addressLine1 && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-green text-sm animate-scale-in">✓</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Postcode"
|
|
value={postcode}
|
|
onChange={(e) => setPostcode(e.target.value.toUpperCase())}
|
|
autoComplete="postal-code"
|
|
className="w-full h-12 px-4 rounded-xl border-2 border-gray-200 bg-white text-sm font-medium placeholder:text-gray-300 focus:border-success-green focus:ring-4 focus:ring-success-green/10 outline-none transition-all uppercase"
|
|
/>
|
|
{postcode.length >= 5 && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-green text-sm animate-scale-in">✓</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* HMRC declaration — exact model wording */}
|
|
<div className="rounded-xl bg-success-green/5 border border-success-green/10 p-3">
|
|
<p className="text-xs font-bold text-gray-700 mb-1.5">Gift Aid Declaration</p>
|
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
{GIFT_AID_DECLARATION}
|
|
</p>
|
|
{name && (
|
|
<p className="text-[11px] text-muted-foreground mt-2 pt-2 border-t border-success-green/10">
|
|
By ticking Gift Aid above, <strong>{name}</strong> declares this donation{orgName ? ` to ${orgName}` : ""} is eligible for Gift Aid.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{(!addressLine1 || postcode.length < 5) && (
|
|
<p className="text-xs text-warm-amber flex items-center gap-1.5">
|
|
<ChevronDown className="h-3 w-3" />
|
|
Please complete your address to claim Gift Aid
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Communication Consent (GDPR / PECR) ── */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-bold text-gray-700 ml-1">Communication preferences</p>
|
|
|
|
{/* Email consent — only shown if email is provided */}
|
|
{hasEmail && (
|
|
<ConsentCheckbox
|
|
checked={emailOptIn}
|
|
onChange={setEmailOptIn}
|
|
icon="📧"
|
|
label="Email updates"
|
|
description={EMAIL_CONSENT_TEXT}
|
|
/>
|
|
)}
|
|
|
|
{/* WhatsApp consent — only shown if phone is provided */}
|
|
{hasPhone && (
|
|
<ConsentCheckbox
|
|
checked={whatsappOptIn}
|
|
onChange={setWhatsappOptIn}
|
|
icon="💬"
|
|
label="WhatsApp updates"
|
|
description={WHATSAPP_CONSENT_TEXT}
|
|
/>
|
|
)}
|
|
|
|
{!hasEmail && !hasPhone && (
|
|
<p className="text-xs text-muted-foreground ml-1">
|
|
Enter an email or mobile number above to set your communication preferences.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Submit ── */}
|
|
<Button
|
|
size="xl"
|
|
className={`w-full transition-all duration-300 ${isValid ? "opacity-100" : "opacity-50"}`}
|
|
disabled={!isValid || submitting}
|
|
onClick={handleSubmit}
|
|
>
|
|
{submitting ? (
|
|
<span className="flex items-center gap-2">
|
|
<span className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Submitting...
|
|
</span>
|
|
) : (
|
|
"Complete Pledge ✓"
|
|
)}
|
|
</Button>
|
|
|
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
<Shield className="h-3 w-3" />
|
|
<span>Your data is encrypted and only shared with the charity you're pledging to</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── Reusable consent checkbox ── */
|
|
function ConsentCheckbox({ checked, onChange, icon, label, description }: {
|
|
checked: boolean
|
|
onChange: (v: boolean) => void
|
|
icon: string
|
|
label: string
|
|
description: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(!checked)}
|
|
className={`w-full text-left rounded-2xl border-2 p-4 transition-all ${
|
|
checked
|
|
? "border-trust-blue bg-trust-blue/5 shadow-sm"
|
|
: "border-gray-200 bg-white hover:border-trust-blue/40"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`mt-0.5 w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center transition-all ${
|
|
checked ? "bg-trust-blue border-trust-blue" : "border-gray-300"
|
|
}`}>
|
|
{checked && <span className="text-white text-xs font-bold">✓</span>}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="font-bold text-sm">{icon} {label}</span>
|
|
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|