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:
Submodule pledge-now-pay-later/pnpl-backup deleted from 38833783a2
@@ -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;
|
||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user