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)
This commit is contained in:
2026-03-03 07:38:51 +08:00
parent e6b7f325da
commit 865c5a1f93
10 changed files with 468 additions and 154 deletions

Submodule pledge-now-pay-later/pnpl-backup deleted from 38833783a2

View File

@@ -0,0 +1,16 @@
-- Bulletproof consent: Gift Aid (HMRC), email opt-in, WhatsApp opt-in
-- Each consent is tracked with timestamp, exact declaration text, IP, user agent
-- Home address for HMRC Gift Aid claims
ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "donorAddressLine1" TEXT;
ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "donorPostcode" TEXT;
-- Gift Aid timestamp (when declaration was made)
ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "giftAidAt" TIMESTAMP(3);
-- Communication consent (GDPR/PECR)
ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "emailOptIn" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "whatsappOptIn" BOOLEAN NOT NULL DEFAULT false;
-- Consent audit trail (JSON blob — immutable evidence of what was shown + agreed)
ALTER TABLE "Pledge" ADD COLUMN IF NOT EXISTS "consentMeta" JSONB;

View File

@@ -102,8 +102,24 @@ model Pledge {
donorName String? donorName String?
donorEmail String? donorEmail String?
donorPhone String? donorPhone String?
// --- Home address (required by HMRC for Gift Aid claims) ---
donorAddressLine1 String?
donorPostcode String?
// --- Gift Aid (HMRC) ---
giftAid Boolean @default(false) giftAid Boolean @default(false)
giftAidAt DateTime? // when the declaration was made
isZakat Boolean @default(false) // donor marked this as Zakat isZakat Boolean @default(false) // donor marked this as Zakat
// --- Communication consent (GDPR / PECR) ---
emailOptIn Boolean @default(false)
whatsappOptIn Boolean @default(false)
// --- Consent audit trail (immutable evidence) ---
// Stores exact text shown, timestamps, IP, user agent per consent type
consentMeta Json?
iPaidClickedAt DateTime? iPaidClickedAt DateTime?
notes String? notes String?

View File

@@ -62,8 +62,8 @@ export async function GET(request: NextRequest) {
const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000) const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)
try { try {
// WhatsApp channel // WhatsApp channel — only if donor consented
if (channel === "whatsapp" && phone && whatsappReady) { if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) {
const result = await sendPledgeReminder(phone, { const result = await sendPledgeReminder(phone, {
donorName: pledge.donorName || undefined, donorName: pledge.donorName || undefined,
amountPounds: (pledge.amountPence / 100).toFixed(0), amountPounds: (pledge.amountPence / 100).toFixed(0),
@@ -96,8 +96,8 @@ export async function GET(request: NextRequest) {
} }
} }
} }
// Email channel (exposed via webhook API for external tools like n8n/Zapier) // Email channel — only if donor consented
else if (channel === "email" && email) { else if (channel === "email" && email && pledge.emailOptIn) {
// Generate content and store for external pickup // Generate content and store for external pickup
const payload = reminder.payload as Record<string, string> || {} const payload = reminder.payload as Record<string, string> || {}
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null

View File

@@ -8,10 +8,16 @@ interface ExportPledge {
donorName: string | null donorName: string | null
donorEmail: string | null donorEmail: string | null
donorPhone: string | null donorPhone: string | null
donorAddressLine1: string | null
donorPostcode: string | null
amountPence: number amountPence: number
rail: string rail: string
status: string status: string
giftAid: boolean giftAid: boolean
giftAidAt: Date | null
isZakat: boolean
emailOptIn: boolean
whatsappOptIn: boolean
createdAt: Date createdAt: Date
paidAt: Date | null paidAt: Date | null
event: { name: string } event: { name: string }
@@ -50,6 +56,8 @@ export async function GET(request: NextRequest) {
donor_name: p.donorName || "", donor_name: p.donorName || "",
donor_email: p.donorEmail || "", donor_email: p.donorEmail || "",
donor_phone: p.donorPhone || "", donor_phone: p.donorPhone || "",
donor_address: p.donorAddressLine1 || "",
donor_postcode: p.donorPostcode || "",
amount_gbp: (p.amountPence / 100).toFixed(2), amount_gbp: (p.amountPence / 100).toFixed(2),
payment_method: p.rail, payment_method: p.rail,
status: p.status, status: p.status,
@@ -58,6 +66,10 @@ export async function GET(request: NextRequest) {
volunteer_name: p.qrSource?.volunteerName || "", volunteer_name: p.qrSource?.volunteerName || "",
table_name: p.qrSource?.tableName || "", table_name: p.qrSource?.tableName || "",
gift_aid: p.giftAid ? "Yes" : "No", gift_aid: p.giftAid ? "Yes" : "No",
gift_aid_declared_at: p.giftAidAt?.toISOString() || "",
is_zakat: p.isZakat ? "Yes" : "No",
email_opt_in: p.emailOptIn ? "Yes" : "No",
whatsapp_opt_in: p.whatsappOptIn ? "Yes" : "No",
pledged_at: p.createdAt.toISOString(), pledged_at: p.createdAt.toISOString(),
paid_at: p.paidAt?.toISOString() || "", paid_at: p.paidAt?.toISOString() || "",
days_to_collect: p.paidAt days_to_collect: p.paidAt

View File

@@ -105,7 +105,13 @@ export async function POST(request: NextRequest) {
) )
} }
const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, isZakat, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data
// Capture IP for consent audit trail
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| "unknown"
const consentMetaWithIp = consentMeta ? { ...consentMeta, ip } : undefined
// Get event + org // Get event + org
const event = await prisma.event.findUnique({ const event = await prisma.event.findUnique({
@@ -160,8 +166,14 @@ export async function POST(request: NextRequest) {
donorName: donorName || null, donorName: donorName || null,
donorEmail: donorEmail || null, donorEmail: donorEmail || null,
donorPhone: donorPhone || null, donorPhone: donorPhone || null,
donorAddressLine1: donorAddressLine1 || null,
donorPostcode: donorPostcode || null,
giftAid, giftAid,
giftAidAt: giftAid ? new Date() : null,
isZakat: isZakat || false, isZakat: isZakat || false,
emailOptIn: emailOptIn || false,
whatsappOptIn: whatsappOptIn || false,
consentMeta: consentMetaWithIp || undefined,
eventId, eventId,
qrSourceId: qrSourceId || null, qrSourceId: qrSourceId || null,
organizationId: org.id, organizationId: org.id,
@@ -189,8 +201,8 @@ export async function POST(request: NextRequest) {
} }
}) })
// WhatsApp receipt for the plan // WhatsApp receipt for the plan (only if they consented)
if (donorPhone) { if (donorPhone && whatsappOptIn) {
const name = donorName?.split(" ")[0] || "there" const name = donorName?.split(" ")[0] || "there"
const { sendWhatsAppMessage } = await import("@/lib/whatsapp") const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
sendWhatsAppMessage(donorPhone, sendWhatsAppMessage(donorPhone,
@@ -230,8 +242,14 @@ export async function POST(request: NextRequest) {
donorName: donorName || null, donorName: donorName || null,
donorEmail: donorEmail || null, donorEmail: donorEmail || null,
donorPhone: donorPhone || null, donorPhone: donorPhone || null,
donorAddressLine1: donorAddressLine1 || null,
donorPostcode: donorPostcode || null,
giftAid, giftAid,
giftAidAt: giftAid ? new Date() : null,
isZakat: isZakat || false, isZakat: isZakat || false,
emailOptIn: emailOptIn || false,
whatsappOptIn: whatsappOptIn || false,
consentMeta: consentMetaWithIp || undefined,
eventId, eventId,
qrSourceId: qrSourceId || null, qrSourceId: qrSourceId || null,
organizationId: org.id, organizationId: org.id,
@@ -311,8 +329,8 @@ export async function POST(request: NextRequest) {
} }
} }
// Async: Send WhatsApp receipt to donor (non-blocking) // Async: Send WhatsApp receipt to donor (only if they consented)
if (donorPhone) { if (donorPhone && whatsappOptIn) {
sendPledgeReceipt(donorPhone, { sendPledgeReceipt(donorPhone, {
donorName: donorName || undefined, donorName: donorName || undefined,
amountPounds: (amountPence / 100).toFixed(0), amountPounds: (amountPence / 100).toFixed(0),

View File

@@ -20,8 +20,14 @@ export interface PledgeData {
donorName: string donorName: string
donorEmail: string donorEmail: string
donorPhone: string donorPhone: string
donorAddressLine1: string
donorPostcode: string
giftAid: boolean giftAid: boolean
isZakat: boolean isZakat: boolean
emailOptIn: boolean
whatsappOptIn: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
consentMeta?: any
// Scheduling // Scheduling
scheduleMode: "now" | "date" | "installments" scheduleMode: "now" | "date" | "installments"
dueDate?: string dueDate?: string
@@ -64,8 +70,12 @@ export default function PledgePage() {
donorName: "", donorName: "",
donorEmail: "", donorEmail: "",
donorPhone: "", donorPhone: "",
donorAddressLine1: "",
donorPostcode: "",
giftAid: false, giftAid: false,
isZakat: false, isZakat: false,
emailOptIn: false,
whatsappOptIn: false,
scheduleMode: "now", scheduleMode: "now",
}) })
const [pledgeResult, setPledgeResult] = useState<{ const [pledgeResult, setPledgeResult] = useState<{
@@ -137,16 +147,24 @@ export default function PledgePage() {
} }
// Submit pledge (from identity step, or card/DD steps) // Submit pledge (from identity step, or card/DD steps)
const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean; isZakat?: boolean }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const submitPledge = async (identity: any) => {
const finalData = { ...pledgeData, ...identity } const finalData = { ...pledgeData, ...identity }
setPledgeData(finalData) setPledgeData(finalData)
// Inject IP + user agent into consent metadata for audit trail
const consentMeta = finalData.consentMeta ? {
...finalData.consentMeta,
userAgent: navigator.userAgent,
} : undefined
try { try {
const res = await fetch("/api/pledges", { const res = await fetch("/api/pledges", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
...finalData, ...finalData,
consentMeta,
eventId: eventInfo?.id, eventId: eventInfo?.id,
qrSourceId: eventInfo?.qrSourceId, qrSourceId: eventInfo?.qrSourceId,
isZakat: finalData.isZakat || false, isZakat: finalData.isZakat || false,
@@ -209,7 +227,7 @@ export default function PledgePage() {
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />, 0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />, 1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />, 2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} />, 3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />, 4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
5: pledgeResult && ( 5: pledgeResult && (
<ConfirmationStep <ConfirmationStep

View File

@@ -2,42 +2,133 @@
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Gift, Shield, Sparkles, Phone, Mail } from "lucide-react" import { Gift, Shield, Sparkles, Phone, Mail, MapPin, ChevronDown } from "lucide-react"
interface Props { // --- HMRC Gift Aid declaration (exact model wording) ---
onSubmit: (data: { 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 donorName: string
donorEmail: string donorEmail: string
donorPhone: string donorPhone: string
donorAddressLine1: string
donorPostcode: string
giftAid: boolean giftAid: boolean
isZakat?: boolean isZakat: boolean
}) => void emailOptIn: boolean
amount: number whatsappOptIn: boolean
zakatEligible?: 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
}
} }
export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) { 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 [name, setName] = useState("")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [phone, setPhone] = useState("") const [phone, setPhone] = useState("")
const [addressLine1, setAddressLine1] = useState("")
const [postcode, setPostcode] = useState("")
const [giftAid, setGiftAid] = useState(false) const [giftAid, setGiftAid] = useState(false)
const [isZakat, setIsZakat] = useState(false) const [isZakat, setIsZakat] = useState(false)
const [emailOptIn, setEmailOptIn] = useState(false)
const [whatsappOptIn, setWhatsappOptIn] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [contactMode, setContactMode] = useState<"email" | "phone">("email") const [showGiftAidAddress, setShowGiftAidAddress] = useState(false)
const nameRef = useRef<HTMLInputElement>(null) const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => { nameRef.current?.focus() }, []) useEffect(() => { nameRef.current?.focus() }, [])
const hasContact = contactMode === "email" ? email.includes("@") : phone.length >= 10 // When Gift Aid is toggled on, show address fields
const isValid = hasContact 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 giftAidBonus = Math.round(amount * 0.25)
const totalWithAid = amount + giftAidBonus const totalWithAid = amount + giftAidBonus
const handleSubmit = async () => { const handleSubmit = async () => {
if (!isValid) return if (!isValid) return
setSubmitting(true) 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 { try {
await onSubmit({ donorName: name, donorEmail: email, donorPhone: phone, giftAid, isZakat: zakatEligible ? isZakat : false }) 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 { } catch {
setSubmitting(false) setSubmitting(false)
} }
@@ -50,18 +141,18 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) {
Almost there! Almost there!
</h1> </h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
We just need a way to send you payment details We just need your details to process this pledge
</p> </p>
</div> </div>
{/* Minimal form */} {/* ── Contact Details ── */}
<div className="space-y-3"> <div className="space-y-3">
{/* Name */} {/* Name */}
<div className="relative"> <div className="relative">
<input <input
ref={nameRef} ref={nameRef}
type="text" type="text"
placeholder="Your name" placeholder="Full name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
autoComplete="name" autoComplete="name"
@@ -72,87 +163,64 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) {
)} )}
</div> </div>
{/* Contact mode toggle */} {/* Email */}
<div className="flex rounded-xl bg-gray-100 p-1"> <div className="relative">
<button
onClick={() => setContactMode("email")}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-all ${
contactMode === "email" ? "bg-white shadow-sm text-gray-900" : "text-gray-500"
}`}
>
<Mail className="h-4 w-4" /> Email
</button>
<button
onClick={() => setContactMode("phone")}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-all ${
contactMode === "phone" ? "bg-white shadow-sm text-gray-900" : "text-gray-500"
}`}
>
<Phone className="h-4 w-4" /> Mobile
</button>
</div>
{/* Contact input */}
{contactMode === "email" ? (
<div className="relative animate-fade-in">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" /> <Mail className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" />
<input <input
type="email" type="email"
placeholder="your@email.com" placeholder="Email address"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoComplete="email" autoComplete="email"
inputMode="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" 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> </div>
) : (
<div className="relative animate-fade-in"> {/* Phone */}
<div>
<div className="relative">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" /> <Phone className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-300" />
<input <input
type="tel" type="tel"
placeholder="07700 900 000" placeholder="Mobile number (for WhatsApp reminders)"
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
autoComplete="tel" autoComplete="tel"
inputMode="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" 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"
/> />
<p className="text-xs text-muted-foreground mt-1 ml-1"> {hasPhone && (
We&apos;ll send reminders via WhatsApp <div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-green animate-scale-in"></div>
</p>
</div>
)} )}
</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 — only when campaign is Zakat-eligible */} {/* ── Zakat ── */}
{zakatEligible && ( {zakatEligible && (
<button <ConsentCheckbox
onClick={() => setIsZakat(!isZakat)} checked={isZakat}
className={`w-full text-left rounded-2xl border-2 p-4 transition-all ${ onChange={setIsZakat}
isZakat icon="🌙"
? "border-trust-blue bg-trust-blue/5 shadow-sm" label="This is Zakat"
: "border-gray-200 bg-white hover:border-trust-blue/40" description="Mark this pledge as Zakat (obligatory charity). It will be tracked separately."
}`} />
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${isZakat ? "bg-trust-blue border-trust-blue" : "border-gray-300"}`}>
{isZakat && <span className="text-white text-xs font-bold"></span>}
</div>
<div className="flex-1">
<span className="font-bold text-sm">🌙 This is Zakat</span>
<p className="text-xs text-muted-foreground mt-0.5">
Mark this pledge as Zakat (obligatory charity). It will be tracked separately.
</p>
</div>
</div>
</button>
)} )}
{/* Gift Aid — the hero */} {/* ── Gift Aid (HMRC) ── */}
<div className="space-y-3">
<button <button
onClick={() => setGiftAid(!giftAid)} onClick={() => setGiftAid(!giftAid)}
className={`w-full text-left rounded-2xl border-2 p-5 transition-all duration-300 card-hover ${ className={`w-full text-left rounded-2xl border-2 p-5 transition-all duration-300 ${
giftAid giftAid
? "border-success-green bg-gradient-to-br from-success-green/5 to-emerald-50 shadow-lg shadow-success-green/10" ? "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" : "border-gray-200 bg-white hover:border-success-green/40"
@@ -180,6 +248,7 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) {
{giftAid ? ( {giftAid ? (
<div className="mt-2 space-y-2 animate-fade-in"> <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 className="flex items-center justify-between bg-white rounded-xl p-3 border border-success-green/20">
<div> <div>
<p className="text-xs text-muted-foreground">Your pledge</p> <p className="text-xs text-muted-foreground">Your pledge</p>
@@ -196,9 +265,6 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) {
<p className="font-black text-success-green text-lg">£{(totalWithAid / 100).toFixed(0)}</p> <p className="font-black text-success-green text-lg">£{(totalWithAid / 100).toFixed(0)}</p>
</div> </div>
</div> </div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
I confirm I am a UK taxpayer and understand that if I pay less Income Tax and/or Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that tax year, it is my responsibility to pay any difference.
</p>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
@@ -210,7 +276,100 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) {
</div> </div>
</button> </button>
{/* Submit */} {/* 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 <Button
size="xl" size="xl"
className={`w-full transition-all duration-300 ${isValid ? "opacity-100" : "opacity-50"}`} className={`w-full transition-all duration-300 ${isValid ? "opacity-100" : "opacity-50"}`}
@@ -229,8 +388,43 @@ export function IdentityStep({ onSubmit, amount, zakatEligible }: Props) {
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
<span>Your data is encrypted and only used for this pledge</span> <span>Your data is encrypted and only shared with the charity you&apos;re pledging to</span>
</div> </div>
</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>
)
}

View File

@@ -3,6 +3,8 @@ export interface CrmExportRow {
donor_name: string donor_name: string
donor_email: string donor_email: string
donor_phone: string donor_phone: string
donor_address: string
donor_postcode: string
amount_gbp: string amount_gbp: string
payment_method: string payment_method: string
status: string status: string
@@ -11,6 +13,10 @@ export interface CrmExportRow {
volunteer_name: string volunteer_name: string
table_name: string table_name: string
gift_aid: string gift_aid: string
gift_aid_declared_at: string
is_zakat: string
email_opt_in: string
whatsapp_opt_in: string
pledged_at: string pledged_at: string
paid_at: string paid_at: string
days_to_collect: string days_to_collect: string

View File

@@ -25,8 +25,41 @@ export const createPledgeSchema = z.object({
donorName: z.string().max(200).optional().default(''), donorName: z.string().max(200).optional().default(''),
donorEmail: z.string().max(200).optional().default(''), donorEmail: z.string().max(200).optional().default(''),
donorPhone: z.string().max(20).optional().default(''), donorPhone: z.string().max(20).optional().default(''),
// Home address (required for HMRC Gift Aid)
donorAddressLine1: z.string().max(300).optional().default(''),
donorPostcode: z.string().max(10).optional().default(''),
// Gift Aid
giftAid: z.boolean().default(false), giftAid: z.boolean().default(false),
isZakat: z.boolean().default(false), isZakat: z.boolean().default(false),
// Communication consent (GDPR/PECR)
emailOptIn: z.boolean().default(false),
whatsappOptIn: z.boolean().default(false),
// Consent audit trail
consentMeta: z.object({
giftAid: z.object({
declared: z.boolean(),
declarationText: z.string(),
declaredAt: z.string(),
}).optional(),
email: z.object({
granted: z.boolean(),
consentText: z.string(),
grantedAt: z.string(),
}).optional(),
whatsapp: z.object({
granted: z.boolean(),
consentText: z.string(),
grantedAt: z.string(),
}).optional(),
ip: z.string().optional(),
userAgent: z.string().optional(),
consentVersion: z.string().default('v1'),
}).optional(),
eventId: z.string(), eventId: z.string(),
qrSourceId: z.string().nullable().optional(), qrSourceId: z.string().nullable().optional(),
// Payment scheduling // Payment scheduling
@@ -39,6 +72,8 @@ export const createPledgeSchema = z.object({
donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined, donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined,
donorPhone: data.donorPhone && data.donorPhone.length >= 10 ? data.donorPhone : undefined, donorPhone: data.donorPhone && data.donorPhone.length >= 10 ? data.donorPhone : undefined,
donorName: data.donorName || undefined, donorName: data.donorName || undefined,
donorAddressLine1: data.donorAddressLine1 || undefined,
donorPostcode: data.donorPostcode || undefined,
qrSourceId: data.qrSourceId || undefined, qrSourceId: data.qrSourceId || undefined,
})) }))