Files
calvana/pledge-now-pay-later/src/app/p/[token]/steps/identity-step.tsx
Omair Saleh 865c5a1f93 bulletproof consent: Gift Aid (HMRC), email opt-in, WhatsApp opt-in with full audit trail
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)
2026-03-03 07:38:51 +08:00

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