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

@@ -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