feat: conditional & match funding pledges — deeply integrated across entire product

- Schema: isConditional, conditionType, conditionText, conditionThreshold, conditionMet, conditionMetAt on Pledge
- Pledge form: 'This is a match pledge' toggle after amount selection
  - Two modes: threshold (if target is reached) and match (match funding)
  - Goal amount passed through from event
- Auto-trigger: when total raised hits threshold, conditional pledges unlock automatically
  - WhatsApp notification sent to donor when unlocked
  - Threshold check runs after every pledge creation AND every status change
- Cron: skips conditional pledges until conditionMet=true (no premature reminders)
- Dashboard Home: progress bar shows conditional segment (amber), stats grid adds Conditional column
- Dashboard Money: conditional/unlocked badge on pledge rows
- Dashboard Collect: hero shows conditional total in amber
- Dashboard Reports: financial summary shows conditional breakdown
- Donor 'My Pledges': conditional card with condition text + activation status
- Confirmation step: specialized messaging for match pledges
- CRM export: includes is_conditional, condition_type, condition_text, condition_met columns
- Status guide: conditional status explained in human language
This commit is contained in:
2026-03-05 04:19:23 +08:00
parent c11bf4bea7
commit 50d449e2b7
23 changed files with 607 additions and 140 deletions

View File

@@ -135,6 +135,16 @@ model Pledge {
iPaidClickedAt DateTime?
notes String?
// --- Conditional / Match Funding ---
// A conditional pledge activates only when a condition is met.
// e.g. "I'll give £5,000 if you raise £20,000" or "I'll match up to £5,000"
isConditional Boolean @default(false)
conditionType String? // "threshold" | "match" | "custom"
conditionText String? // Human-readable: "If £20,000 is raised"
conditionThreshold Int? // pence — when event total reaches this, condition is met
conditionMet Boolean @default(false)
conditionMetAt DateTime?
// Payment scheduling — the core of "pledge now, pay later"
dueDate DateTime? // null = pay now, set = promise to pay on this date
planId String? // groups installments together

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -46,6 +46,11 @@ export async function GET(request: NextRequest) {
dueDate: { gte: todayStart, lt: todayEnd },
reminderSentForDueDate: false,
status: { notIn: ["paid", "cancelled"] },
// Don't send reminders for conditional pledges that haven't been met
OR: [
{ isConditional: false },
{ isConditional: true, conditionMet: true },
],
},
include: {
event: {
@@ -185,6 +190,11 @@ export async function GET(request: NextRequest) {
scheduledAt: { lte: now },
pledge: {
status: { notIn: ["paid", "cancelled"] },
// Don't send reminders for unmet conditional pledges
OR: [
{ isConditional: false },
{ isConditional: true, conditionMet: true },
],
},
},
include: {

View File

@@ -84,7 +84,12 @@ export async function GET(request: NextRequest) {
}),
]) as [PledgeRow[], AnalyticsRow[]]
const totalPledged = pledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const confirmedPledges = pledges.filter((p: any) => !p.isConditional || p.conditionMet)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const conditionalPledges = pledges.filter((p: any) => p.isConditional && !p.conditionMet)
const totalPledged = confirmedPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalConditional = conditionalPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
const totalCollected = pledges
.filter((p: PledgeRow) => p.status === "paid")
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
@@ -123,6 +128,8 @@ export async function GET(request: NextRequest) {
totalPledges: pledges.length,
totalPledgedPence: totalPledged,
totalCollectedPence: totalCollected,
totalConditionalPence: totalConditional,
conditionalCount: conditionalPledges.length,
collectionRate: Math.round(collectionRate * 100),
overdueRate: Math.round(overdueRate * 100),
},
@@ -148,6 +155,12 @@ export async function GET(request: NextRequest) {
installmentNumber: p.installmentNumber,
installmentTotal: p.installmentTotal,
isDeferred: !!p.dueDate,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isConditional: (p as any).isConditional || false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conditionText: (p as any).conditionText || null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conditionMet: (p as any).conditionMet || false,
createdAt: p.createdAt,
paidAt: p.paidAt,
nextReminder: p.reminders

View File

@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
include: {
_count: { select: { pledges: true, qrSources: true } },
pledges: {
select: { amountPence: true, status: true },
select: { amountPence: true, status: true, isConditional: true, conditionMet: true },
},
},
orderBy: { createdAt: "desc" },
@@ -57,7 +57,12 @@ export async function GET(request: NextRequest) {
externalUrl: e.externalUrl || null,
pledgeCount: e._count.pledges,
qrSourceCount: e._count.qrSources,
totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
totalPledged: e.pledges.filter((p: any) => !p.isConditional || p.conditionMet).reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
totalConditional: e.pledges.filter((p: any) => p.isConditional && !p.conditionMet).reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conditionalCount: e.pledges.filter((p: any) => p.isConditional && !p.conditionMet).length,
totalCollected: e.pledges
.filter((p: PledgeSummary) => p.status === "paid")
.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),

View File

@@ -75,6 +75,12 @@ export async function GET(request: NextRequest) {
days_to_collect: p.paidAt
? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString()
: "",
/* eslint-disable @typescript-eslint/no-explicit-any */
is_conditional: (p as any).isConditional ? "Yes" : "No",
condition_type: (p as any).conditionType || "",
condition_text: (p as any).conditionText || "",
condition_met: (p as any).conditionMet ? "Yes" : "No",
/* eslint-enable @typescript-eslint/no-explicit-any */
}))
const csv = formatCrmExportCsv(rows)

View File

@@ -108,6 +108,35 @@ export async function PATCH(
} catch { /* conversion tracking is best-effort */ }
}
// Re-check conditional thresholds when pledge status changes
// (paid pledges increase total, potentially unlocking conditional pledges)
if (parsed.data.status === "paid" || parsed.data.status === "cancelled") {
try {
// Import checkConditionalThresholds dynamically — it lives in the pledges route
// but we'll inline the logic here for simplicity
const result = await prisma.pledge.aggregate({
where: { eventId: existing.eventId, status: { notIn: ["cancelled"] }, isConditional: false },
_sum: { amountPence: true },
})
const totalRaised = result._sum.amountPence || 0
const unmet = await prisma.pledge.findMany({
where: { eventId: existing.eventId, isConditional: true, conditionMet: false, conditionThreshold: { not: null, lte: totalRaised }, status: { notIn: ["cancelled"] } },
})
const now = new Date()
for (const cp of unmet) {
await prisma.pledge.update({ where: { id: cp.id }, data: { conditionMet: true, conditionMetAt: now } })
if (cp.donorPhone) {
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
const ev = await prisma.event.findUnique({ where: { id: existing.eventId }, select: { name: true } })
sendWhatsAppMessage(cp.donorPhone,
`Assalamu Alaikum ${cp.donorName?.split(" ")[0] || "there"} 🎉\n\nGreat news — *${ev?.name || "the appeal"}* has reached its target!\n\nYour pledge of *£${(cp.amountPence / 100).toFixed(0)}* is now active.\n\nReference: \`${cp.reference}\`\n\nReply *HELP* for payment details.`
).catch(() => {})
}
console.log(`[CONDITIONAL] Unlocked ${cp.reference} — threshold met`)
}
} catch (err) { console.error("[CONDITIONAL] Threshold check failed:", err) }
}
// Log activity
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
await logActivity({

View File

@@ -79,6 +79,13 @@ export async function GET(request: NextRequest) {
volunteerName: p.qrSource?.volunteerName || null,
createdAt: p.createdAt,
paidAt: p.paidAt,
/* eslint-disable @typescript-eslint/no-explicit-any */
isConditional: (p as any).isConditional || false,
conditionType: (p as any).conditionType || null,
conditionText: (p as any).conditionText || null,
conditionThreshold: (p as any).conditionThreshold || null,
conditionMet: (p as any).conditionMet || false,
/* eslint-enable @typescript-eslint/no-explicit-any */
})),
total,
limit,
@@ -117,7 +124,7 @@ export async function POST(request: NextRequest) {
)
}
const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, 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, isConditional, conditionType, conditionText, conditionThreshold } = parsed.data
// Capture IP for consent audit trail
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
@@ -266,6 +273,13 @@ export async function POST(request: NextRequest) {
qrSourceId: qrSourceId || null,
organizationId: org.id,
dueDate: parsedDueDate,
// Conditional / match funding
isConditional: isConditional || false,
conditionType: isConditional ? (conditionType || "custom") : null,
conditionText: isConditional ? (conditionText || null) : null,
conditionThreshold: isConditional ? (conditionThreshold || null) : null,
conditionMet: false,
// Conditional pledges start as "new" but reminders won't fire until conditionMet
},
})
@@ -372,9 +386,75 @@ export async function POST(request: NextRequest) {
}).catch(() => {})
}
// ── AUTO-TRIGGER: Check if any conditional pledges' thresholds are now met ──
// After every new pledge, calculate total raised for this event
// and unlock any conditional pledges whose threshold has been reached.
if (prisma) {
checkConditionalThresholds(eventId).catch(err =>
console.error("[CONDITIONAL] Threshold check failed:", err)
)
}
return NextResponse.json(response, { status: 201 })
} catch (error) {
console.error("Pledge creation error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
/**
* Check if any conditional pledges for this event have had their threshold met.
* If total raised (non-conditional, non-cancelled pledges) >= threshold, unlock them.
*/
async function checkConditionalThresholds(eventId: string) {
if (!prisma) return
// Get all non-cancelled, non-conditional pledge totals for this event
const result = await prisma.pledge.aggregate({
where: {
eventId,
status: { notIn: ["cancelled"] },
isConditional: false,
},
_sum: { amountPence: true },
})
const totalRaised = result._sum.amountPence || 0
// Find conditional pledges with threshold that's now met
const unmetConditionals = await prisma.pledge.findMany({
where: {
eventId,
isConditional: true,
conditionMet: false,
conditionThreshold: { not: null, lte: totalRaised },
status: { notIn: ["cancelled"] },
},
})
if (unmetConditionals.length === 0) return
// Unlock them
const now = new Date()
for (const p of unmetConditionals) {
await prisma.pledge.update({
where: { id: p.id },
data: { conditionMet: true, conditionMetAt: now },
})
// Send activation WhatsApp if they have a phone
if (p.donorPhone) {
try {
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
const name = p.donorName?.split(" ")[0] || "there"
const amount = (p.amountPence / 100).toFixed(0)
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { name: true } })
await sendWhatsAppMessage(p.donorPhone,
`Assalamu Alaikum ${name} 🎉\n\nGreat news — *${event?.name || "the appeal"}* has reached its target!\n\nYour pledge of *£${amount}* is now active.\n\nReference: \`${p.reference}\`\n\nReply *HELP* for payment details.`
)
} catch { /* */ }
}
console.log(`[CONDITIONAL] Unlocked pledge ${p.reference}${(p.amountPence / 100).toFixed(0)}) — threshold £${((p.conditionThreshold || 0) / 100).toFixed(0)} met (total: £${(totalRaised / 100).toFixed(0)})`)
}
}

View File

@@ -33,6 +33,7 @@ export async function GET(
externalPlatform: event.externalPlatform || null,
zakatEligible: event.zakatEligible || false,
hasStripe: !!event.organization.stripeSecretKey,
goalAmount: event.goalAmount || null,
})
}
@@ -68,6 +69,7 @@ export async function GET(
externalPlatform: qrSource.event.externalPlatform || null,
zakatEligible: qrSource.event.zakatEligible || false,
hasStripe: !!qrSource.event.organization.stripeSecretKey,
goalAmount: qrSource.event.goalAmount || null,
})
} catch (error) {
console.error("QR resolve error:", error)

View File

@@ -211,6 +211,8 @@ export default function CollectPage() {
// Stats
const totalPledges = events.reduce((s, e) => s + e.pledgeCount, 0)
const totalPledged = events.reduce((s, e) => s + e.totalPledged, 0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const totalConditional = events.reduce((s, e) => s + ((e as any).totalConditional || 0), 0)
// Sort sources by pledges
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedSources = [...sources].sort((a: any, b: any) => b.totalPledged - a.totalPledged)
@@ -548,8 +550,14 @@ export default function CollectPage() {
</div>
<div>
<p className="text-2xl md:text-3xl font-black text-[#4ADE80] tracking-tight">{formatPence(totalPledged)}</p>
<p className="text-[10px] text-gray-500 mt-0.5">raised</p>
<p className="text-[10px] text-gray-500 mt-0.5">confirmed</p>
</div>
{totalConditional > 0 && (
<div>
<p className="text-2xl md:text-3xl font-black text-[#FBBF24] tracking-tight">{formatPence(totalConditional)}</p>
<p className="text-[10px] text-gray-500 mt-0.5">conditional</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -69,7 +69,7 @@ export default function ReportsPage() {
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, totalConditionalPence: 0, conditionalCount: 0, collectionRate: 0 }
const byStatus = dash?.byStatus || {}
const outstanding = s.totalPledgedPence - s.totalCollectedPence
const giftAidPledges = (dash?.pledges || []).filter((p: { giftAid: boolean; status: string }) => p.giftAid && p.status === "paid")
@@ -111,9 +111,10 @@ export default function ReportsPage() {
{/* ── Financial breakdown ── */}
<div className="bg-[#111827] p-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-700">
<div className={`grid grid-cols-2 ${s.totalConditionalPence > 0 ? "lg:grid-cols-5" : "lg:grid-cols-4"} gap-px bg-gray-700`}>
{[
{ value: formatPence(s.totalPledgedPence), label: "Total promised", color: "text-white" },
{ value: formatPence(s.totalPledgedPence), label: "Confirmed pledges", color: "text-white" },
...(s.totalConditionalPence > 0 ? [{ value: formatPence(s.totalConditionalPence), label: `Conditional (${s.conditionalCount})`, color: "text-[#FBBF24]" }] : []),
{ value: formatPence(s.totalCollectedPence), label: "Total received", color: "text-[#4ADE80]" },
{ value: formatPence(outstanding), label: "Still outstanding", color: outstanding > 0 ? "text-[#FBBF24]" : "text-white" },
{ value: `${s.collectionRate}%`, label: "Collection rate", color: s.collectionRate >= 70 ? "text-[#4ADE80]" : "text-[#FBBF24]" },

View File

@@ -22,9 +22,16 @@ const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
conditional:{ label: "🤝 Conditional", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getStatusLabel(p: any) {
if (p.isConditional && !p.conditionMet) return STATUS_LABELS.conditional
return STATUS_LABELS[p.status] || STATUS_LABELS.new
}
export default function DashboardPage() {
const router = useRouter()
const { data: session } = useSession()
@@ -96,7 +103,7 @@ export default function DashboardPage() {
}
}
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, totalConditionalPence: 0, conditionalCount: 0, collectionRate: 0 }
const byStatus = data?.byStatus || {}
const pledges = data?.pledges || []
const topSources = data?.topSources || []
@@ -278,10 +285,11 @@ export default function DashboardPage() {
{!isEmpty && (
<>
{/* Stats — gap-px grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
<div className={`grid grid-cols-2 ${s.totalConditionalPence > 0 ? "lg:grid-cols-5" : "lg:grid-cols-4"} gap-px bg-gray-200`}>
{[
{ value: String(s.totalPledges), label: "Pledges" },
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
{ value: formatPence(s.totalPledgedPence), label: "Confirmed" },
...(s.totalConditionalPence > 0 ? [{ value: formatPence(s.totalConditionalPence), label: `Conditional (${s.conditionalCount})`, accent: "text-[#F59E0B]" }] : []),
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: "text-[#16A34A]" },
{ value: `${s.collectionRate}%`, label: "Collected" },
].map(stat => (
@@ -292,18 +300,26 @@ export default function DashboardPage() {
))}
</div>
{/* Progress bar */}
{/* Progress bar — with conditional segment */}
<div className="bg-white p-5">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-bold text-[#111827]">Promised Received</span>
<span className="text-sm font-black text-[#111827]">{s.collectionRate}%</span>
</div>
<div className="h-3 bg-gray-100 overflow-hidden">
<div className="h-3 bg-gray-100 overflow-hidden flex">
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
{s.totalConditionalPence > 0 && (
<div className="h-full bg-[#F59E0B]/30 transition-all duration-700 border-l border-white"
style={{ width: `${Math.round((s.totalConditionalPence / (s.totalPledgedPence + s.totalConditionalPence)) * 100)}%` }}
title={`${formatPence(s.totalConditionalPence)} conditional — unlocks when conditions are met`} />
)}
</div>
<div className="flex justify-between mt-2 text-xs text-gray-500">
<span>{formatPence(s.totalCollectedPence)} received</span>
<span>{formatPence(outstanding)} still to come</span>
{s.totalConditionalPence > 0 && (
<span className="text-[#F59E0B] font-bold">+ {formatPence(s.totalConditionalPence)} conditional</span>
)}
</div>
</div>
@@ -332,8 +348,8 @@ export default function DashboardPage() {
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
</div>
<div className="divide-y divide-gray-50">
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string }) => {
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; isConditional?: boolean; conditionMet?: boolean }) => {
const sl = getStatusLabel(p)
return (
<div key={p.id} className="px-5 py-3 flex items-center justify-between">
<div>
@@ -362,8 +378,9 @@ export default function DashboardPage() {
id: string; donorName: string | null; amountPence: number; status: string;
eventName: string; createdAt: string; donorPhone: string | null;
installmentNumber: number | null; installmentTotal: number | null;
isConditional?: boolean; conditionMet?: boolean;
}) => {
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
const sl = getStatusLabel(p)
const initial = (p.donorName || "A")[0].toUpperCase()
const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : days < 7 ? `${days}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
@@ -479,6 +496,7 @@ export default function DashboardPage() {
{ label: "Said they paid", desc: "Donor replied PAID — upload bank statement to confirm" },
{ label: "Received ✓", desc: "Payment confirmed in your bank account" },
{ label: "Needs a nudge", desc: "It's been a while — you can send a manual reminder" },
{ label: "🤝 Conditional", desc: "Match or conditional pledge — activates when the target is reached" },
].map(s => (
<div key={s.label} className="flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[#1E40AF] shrink-0 mt-1.5" />

View File

@@ -44,6 +44,8 @@ interface Pledge {
installmentNumber: number | null; installmentTotal: number | null
eventName: string; qrSourceLabel: string | null; volunteerName: string | null
createdAt: string; paidAt: string | null
// Conditional / match funding
isConditional: boolean; conditionText: string | null; conditionMet: boolean
}
interface MatchResult {
@@ -662,8 +664,14 @@ export default function MoneyPage() {
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
</div>
<div className="col-span-2">
<div className="col-span-2 flex flex-wrap gap-1">
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
{p.isConditional && !p.conditionMet && (
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B] inline-block" title={p.conditionText || "Conditional pledge"}>🤝 Conditional</span>
)}
{p.isConditional && p.conditionMet && (
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A] inline-block">🤝 Unlocked</span>
)}
</div>
<div className="col-span-1 hidden md:block">
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>

View File

@@ -33,6 +33,11 @@ export interface PledgeData {
dueDate?: string
installmentCount?: number
installmentDates?: string[]
// Conditional / match funding
isConditional: boolean
conditionType?: "threshold" | "match" | "custom"
conditionText?: string
conditionThreshold?: number
}
interface EventInfo {
@@ -46,6 +51,7 @@ interface EventInfo {
externalPlatform: string | null
zakatEligible: boolean
hasStripe: boolean
goalAmount: number | null
}
/*
@@ -78,6 +84,7 @@ export default function PledgePage() {
emailOptIn: false,
whatsappOptIn: false,
scheduleMode: "now",
isConditional: false,
})
const [pledgeResult, setPledgeResult] = useState<{
id: string
@@ -106,14 +113,14 @@ export default function PledgePage() {
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
// Step 0: Amount selected
const handleAmountSelected = (amountPence: number) => {
setPledgeData((d) => ({ ...d, amountPence }))
const handleAmountSelected = (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => {
const conditionalData = conditional || { isConditional: false }
setPledgeData((d) => ({ ...d, amountPence, ...conditionalData }))
if (isExternal) {
// External events: amount → identity → redirect (skip schedule + payment method)
setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now" }))
setStep(3) // → Identity
setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now", ...conditionalData }))
setStep(3)
} else {
setStep(1) // → Schedule step
setStep(1)
}
}
@@ -225,7 +232,7 @@ export default function PledgePage() {
: undefined
const steps: Record<number, React.ReactNode> = {
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} goalAmount={eventInfo?.goalAmount} />,
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
@@ -242,6 +249,8 @@ export default function PledgePage() {
dueDateLabel={dueDateLabel}
installmentCount={pledgeData.installmentCount}
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
isConditional={pledgeData.isConditional}
conditionText={pledgeData.conditionText}
/>
),
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,

View File

@@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button"
import { Heart, Sparkles, TrendingUp } from "lucide-react"
interface Props {
onSelect: (amountPence: number) => void
onSelect: (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => void
eventName: string
eventId?: string
goalAmount?: number | null
}
interface AiSuggestion {
@@ -18,13 +19,18 @@ interface AiSuggestion {
const FALLBACK_AMOUNTS = [2000, 5000, 10000, 25000, 50000, 100000]
export function AmountStep({ onSelect, eventName, eventId }: Props) {
export function AmountStep({ onSelect, eventName, eventId, goalAmount }: Props) {
const [custom, setCustom] = useState("")
const [selected, setSelected] = useState<number | null>(null)
const [suggestions, setSuggestions] = useState<AiSuggestion | null>(null)
const [hovering, setHovering] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Conditional / match funding
const [isMatch, setIsMatch] = useState(false)
const [matchType, setMatchType] = useState<"threshold" | "match">("threshold")
const [matchThreshold, setMatchThreshold] = useState("")
// Fetch AI-powered suggestions
useEffect(() => {
const url = eventId ? `/api/ai/suggest?eventId=${eventId}` : "/api/ai/suggest"
@@ -53,7 +59,22 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
const handleContinue = () => {
const amount = selected || Math.round(parseFloat(custom) * 100)
if (amount >= 100) onSelect(amount)
if (amount >= 100) {
if (isMatch) {
const thresholdPence = matchThreshold ? Math.round(parseFloat(matchThreshold) * 100) : (goalAmount || 0)
const conditionText = matchType === "match"
? `I'll match up to £${(amount / 100).toFixed(0)}`
: `I'll give £${(amount / 100).toFixed(0)} if £${(thresholdPence / 100).toFixed(0)} is raised`
onSelect(amount, {
isConditional: true,
conditionType: matchType,
conditionText,
conditionThreshold: thresholdPence,
})
} else {
onSelect(amount)
}
}
}
const activeAmount = selected || (custom ? Math.round(parseFloat(custom) * 100) : 0)
@@ -176,6 +197,78 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
</div>
)}
{/* Match / conditional pledge */}
{isValid && (
<div className="space-y-3">
<button
onClick={() => setIsMatch(!isMatch)}
className={`w-full text-left rounded-lg border-2 p-4 transition-all ${
isMatch ? "border-trust-blue bg-trust-blue/5" : "border-gray-100 hover:border-gray-200"
}`}
>
<div className="flex items-center gap-3">
<span className="text-xl">🤝</span>
<div>
<p className="text-sm font-bold text-gray-900">This is a match pledge</p>
<p className="text-xs text-gray-500">
I&apos;ll give this amount only if a condition is met
</p>
</div>
</div>
</button>
{isMatch && (
<div className="rounded-lg border border-gray-200 p-4 space-y-3 animate-fade-in">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setMatchType("threshold")}
className={`p-3 text-center rounded-lg border-2 text-xs font-bold transition-all ${
matchType === "threshold" ? "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-gray-500"
}`}
>
If target is reached
</button>
<button
onClick={() => setMatchType("match")}
className={`p-3 text-center rounded-lg border-2 text-xs font-bold transition-all ${
matchType === "match" ? "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-gray-500"
}`}
>
Match funding
</button>
</div>
{matchType === "threshold" && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-600">
I&apos;ll give £{(activeAmount / 100).toFixed(0)} if the appeal raises:
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-bold">£</span>
<input
type="text" inputMode="numeric"
value={matchThreshold}
onChange={e => setMatchThreshold(e.target.value.replace(/[^0-9]/g, ""))}
placeholder={goalAmount ? (goalAmount / 100).toFixed(0) : "10000"}
className="w-full pl-8 pr-3 h-10 rounded-lg border-2 border-gray-200 text-sm font-bold focus:border-trust-blue outline-none"
/>
</div>
</div>
)}
{matchType === "match" && (
<div className="rounded-lg bg-trust-blue/5 p-3">
<p className="text-xs text-gray-700">
You&apos;ll match other donors&apos; pledges, up to <strong>£{(activeAmount / 100).toFixed(0)}</strong>.
Your pledge activates when the appeal reaches £{(activeAmount / 100).toFixed(0)} from other donors.
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Continue */}
<Button
size="xl"

View File

@@ -16,6 +16,8 @@ interface Props {
dueDateLabel?: string
installmentCount?: number
installmentAmount?: number
isConditional?: boolean
conditionText?: string
}
// Mini confetti
@@ -54,7 +56,7 @@ function Confetti() {
)
}
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone, isDeferred, dueDateLabel, installmentCount, installmentAmount }: Props) {
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone, isDeferred, dueDateLabel, installmentCount, installmentAmount, isConditional, conditionText }: Props) {
const [copied, setCopied] = useState(false)
const [whatsappSent, setWhatsappSent] = useState(false)
@@ -138,13 +140,27 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
<div className="space-y-2">
<h1 className="text-2xl font-black text-gray-900">
{isDeferred
? "Pledge Locked In!"
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
{isConditional
? "Match Pledge Registered!"
: isDeferred
? "Pledge Locked In!"
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous support of{" "}
<span className="font-semibold text-foreground">{eventName}</span>
{isConditional ? (
<>
Your match pledge of{" "}
<span className="font-semibold text-foreground">£{(amount / 100).toFixed(0)}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span> has been registered.
{conditionText && <span className="block mt-1 text-amber-600 font-medium text-sm">{conditionText}</span>}
<span className="block mt-1 text-xs">We&apos;ll notify you when the condition is met and your pledge activates.</span>
</>
) : (
<>
Thank you for your generous support of{" "}
<span className="font-semibold text-foreground">{eventName}</span>
</>
)}
</p>
</div>

View File

@@ -11,6 +11,7 @@ function MyPledgesForm() {
createdAt: string; paidAt: string | null; dueDate: string | null;
installmentNumber: number | null; installmentTotal: number | null;
bankDetails?: { sortCode: string; accountNo: string; accountName: string };
isConditional?: boolean; conditionText?: string; conditionMet?: boolean;
}> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
@@ -110,6 +111,16 @@ function MyPledgesForm() {
{p.installmentNumber && <p><strong>Instalment:</strong> {p.installmentNumber} of {p.installmentTotal}</p>}
{p.dueDate && <p><strong>Due:</strong> {new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
{p.paidAt && <p className="text-green-700"><strong>Paid:</strong> {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
{p.isConditional && !p.conditionMet && (
<div className="bg-amber-50 border border-amber-200 rounded p-2 mt-1">
<p className="text-amber-700 text-xs font-bold">🤝 Conditional pledge</p>
{p.conditionText && <p className="text-amber-600 text-xs mt-0.5">{p.conditionText}</p>}
<p className="text-amber-500 text-[10px] mt-0.5">Payment details will be sent when the condition is met</p>
</div>
)}
{p.isConditional && p.conditionMet && (
<p className="text-green-700 text-xs font-bold">🤝 Condition met pledge activated!</p>
)}
</div>
{p.status !== "paid" && p.status !== "cancelled" && p.bankDetails && (
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">

View File

@@ -20,7 +20,12 @@ export const createQrSourceSchema = z.object({
})
export const createPledgeSchema = z.object({
amountPence: z.number().int().min(100).max(100000000), // £1 to £1M
amountPence: z.number().int().min(100).max(100000000),
// Conditional / match funding
isConditional: z.boolean().default(false),
conditionType: z.enum(["threshold", "match", "custom"]).optional(),
conditionText: z.string().max(500).optional(),
conditionThreshold: z.number().int().positive().optional(),
rail: z.enum(['bank', 'gocardless', 'card']),
donorName: z.string().max(200).optional().default(''),
donorEmail: z.string().max(200).optional().default(''),