feat: deferred payments & installment plans — pledge = promise to pay on a date

CORE PRODUCT SHIFT:
A pledge is now a promise to pay on a future date, not just 'pay now'.

NEW FLOW: Amount → Schedule → Payment/Identity → Confirmation

SCHEDULE STEP (/p/[token] step 1):
- 'Pay right now' — existing card/DD/bank flow
- 'Pay on a specific date' — calendar picker with smart suggestions
  (This Friday, End of month, Payday 1st, In 2 weeks, In 1 month)
- 'Split into monthly payments' — 2/3/4/6/12 month installment plans
  with per-installment breakdown and date schedule

SCHEMA CHANGES:
- Pledge.dueDate — when the donor promises to pay (null = now)
- Pledge.planId — groups installment pledges together
- Pledge.installmentNumber / installmentTotal — e.g. 2 of 4
- Pledge.reminderSentForDueDate — tracking flag
- New indexes on dueDate+status and planId

INSTALLMENT PLANS:
- Creates N linked Pledge records with shared planId
- Each installment gets its own reference, due date, reminders
- Reminders: 2 days before, on due date, 3 days after, 10 days after
- WhatsApp receipt shows full plan summary

DEFERRED SINGLE PLEDGES:
- Reminders anchored to due date, not creation date
- 'Pay on date' → reminders: 2 days before, on day, +3d nudge, +10d final
- WhatsApp preferred when phone number provided

DASHBOARD:
- API returns dueDate, planId, installment info for each pledge
- Confirmation step shows schedule details for deferred pledges
This commit is contained in:
2026-03-03 04:43:19 +08:00
parent c6e7e4f01e
commit 250221b530
8 changed files with 676 additions and 42 deletions

View File

@@ -12,6 +12,10 @@ interface Props {
eventName: string
shareUrl?: string
donorPhone?: string
isDeferred?: boolean
dueDateLabel?: string
installmentCount?: number
installmentAmount?: number
}
// Mini confetti
@@ -50,7 +54,7 @@ function Confetti() {
)
}
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone }: Props) {
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone, isDeferred, dueDateLabel, installmentCount, installmentAmount }: Props) {
const [copied, setCopied] = useState(false)
const [whatsappSent, setWhatsappSent] = useState(false)
@@ -60,8 +64,14 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
card: "Card Payment",
}
const deferredMessage = isDeferred
? installmentCount && installmentCount > 1
? `You've pledged ${installmentCount} payments of £${((installmentAmount || 0) / 100).toFixed(0)}/month. We'll send you payment details before each due date via ${donorPhone ? "WhatsApp" : "email"}.`
: `Your payment is scheduled for ${dueDateLabel}. We'll send you payment details on the day via ${donorPhone ? "WhatsApp" : "email"}. No action needed until then.`
: null
const nextStepMessages: Record<string, string> = {
bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
gocardless: `Your Direct Debit mandate is set up. £${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`,
card: "Your card payment has been processed. Confirmation email is on its way.",
}
@@ -128,7 +138,9 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
<div className="space-y-2">
<h1 className="text-2xl font-black text-gray-900">
{rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
{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{" "}
@@ -155,7 +167,18 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
{copied ? <Check className="h-3.5 w-3.5 text-success-green" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
{rail === "card" && (
{isDeferred && dueDateLabel && (
<div className="flex justify-between items-center pt-1 border-t">
<span className="text-muted-foreground">{installmentCount && installmentCount > 1 ? "Schedule" : "Payment Date"}</span>
<span className="font-semibold text-warm-amber">
{installmentCount && installmentCount > 1
? `${installmentCount}× £${((installmentAmount || 0) / 100).toFixed(0)}/mo`
: dueDateLabel
}
</span>
</div>
)}
{rail === "card" && !isDeferred && (
<div className="flex justify-between items-center pt-1 border-t">
<span className="text-muted-foreground">Status</span>
<span className="text-success-green font-bold flex items-center gap-1">

View File

@@ -0,0 +1,416 @@
"use client"
import { useState, useMemo } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Zap, Calendar, Repeat, ChevronLeft, ChevronRight, Check } from "lucide-react"
interface Props {
amount: number
onSelect: (schedule: {
mode: "now" | "date" | "installments"
dueDate?: string // ISO date
installmentCount?: number // 2-12
installmentDates?: string[] // ISO dates
}) => void
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
// Smart date suggestions
function getSmartDates(): Array<{ label: string; date: Date; subtext: string }> {
const now = new Date()
const suggestions: Array<{ label: string; date: Date; subtext: string }> = []
// Next Friday
const nextFri = new Date(now)
nextFri.setDate(now.getDate() + ((5 - now.getDay() + 7) % 7 || 7))
suggestions.push({ label: "This Friday", date: nextFri, subtext: formatShort(nextFri) })
// End of this month
const endMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
if (endMonth.getTime() - now.getTime() > 3 * 86400000) {
suggestions.push({ label: "End of month", date: endMonth, subtext: formatShort(endMonth) })
}
// 1st of next month (payday)
const firstNext = new Date(now.getFullYear(), now.getMonth() + 1, 1)
suggestions.push({ label: "Payday (1st)", date: firstNext, subtext: formatShort(firstNext) })
// 2 weeks from now
const twoWeeks = new Date(now)
twoWeeks.setDate(now.getDate() + 14)
suggestions.push({ label: "In 2 weeks", date: twoWeeks, subtext: formatShort(twoWeeks) })
// 1 month from now
const oneMonth = new Date(now)
oneMonth.setMonth(now.getMonth() + 1)
suggestions.push({ label: "In 1 month", date: oneMonth, subtext: formatShort(oneMonth) })
return suggestions
}
function formatShort(d: Date): string {
return `${d.getDate()} ${MONTHS[d.getMonth()]}`
}
function formatFull(d: Date): string {
return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`
}
export function ScheduleStep({ amount, onSelect }: Props) {
const [mode, setMode] = useState<"choice" | "calendar" | "installments">("choice")
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [calendarMonth, setCalendarMonth] = useState(() => {
const now = new Date()
return { year: now.getFullYear(), month: now.getMonth() }
})
const [installmentCount, setInstallmentCount] = useState(3)
const pounds = (amount / 100).toFixed(0)
const smartDates = useMemo(() => getSmartDates(), [])
// Calendar grid
const calendarDays = useMemo(() => {
const firstDay = new Date(calendarMonth.year, calendarMonth.month, 1)
const lastDay = new Date(calendarMonth.year, calendarMonth.month + 1, 0)
const startPad = firstDay.getDay() // 0=Sun
const days: Array<{ date: Date; inMonth: boolean; isPast: boolean }> = []
// Padding
for (let i = 0; i < startPad; i++) {
const d = new Date(firstDay)
d.setDate(d.getDate() - (startPad - i))
days.push({ date: d, inMonth: false, isPast: true })
}
const today = new Date()
today.setHours(0, 0, 0, 0)
for (let i = 1; i <= lastDay.getDate(); i++) {
const d = new Date(calendarMonth.year, calendarMonth.month, i)
days.push({ date: d, inMonth: true, isPast: d < today })
}
// Pad to 42 (6 rows)
while (days.length < 42) {
const d = new Date(lastDay)
d.setDate(lastDay.getDate() + (days.length - startPad - lastDay.getDate() + 1))
days.push({ date: d, inMonth: false, isPast: false })
}
return days
}, [calendarMonth])
// Installment calculator
const installmentDates = useMemo(() => {
const dates: Date[] = []
const start = new Date()
start.setMonth(start.getMonth() + 1)
start.setDate(1) // First of each month
for (let i = 0; i < installmentCount; i++) {
const d = new Date(start)
d.setMonth(start.getMonth() + i)
dates.push(d)
}
return dates
}, [installmentCount])
const perInstallment = Math.ceil(amount / installmentCount)
const handleDateConfirm = () => {
if (!selectedDate) return
onSelect({ mode: "date", dueDate: selectedDate.toISOString() })
}
const handleInstallmentConfirm = () => {
onSelect({
mode: "installments",
installmentCount,
installmentDates: installmentDates.map(d => d.toISOString()),
})
}
// === MAIN CHOICE SCREEN ===
if (mode === "choice") {
return (
<div className="max-w-md mx-auto pt-2 space-y-5 animate-fade-up">
<div className="text-center space-y-2">
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
When would you like to pay?
</h1>
<p className="text-muted-foreground text-sm">
Your £{pounds} pledge choose what works for you
</p>
</div>
<div className="space-y-3 stagger-children">
{/* Pay Now */}
<button
onClick={() => onSelect({ mode: "now" })}
className="w-full text-left rounded-2xl border-2 border-gray-200 bg-white p-5 transition-all duration-200 card-hover hover:border-trust-blue/40 hover:shadow-lg group"
>
<div className="flex items-start gap-4">
<div className="rounded-xl bg-gradient-to-br from-trust-blue to-blue-600 p-3 shadow-lg shadow-trust-blue/20 group-hover:scale-105 transition-transform">
<Zap className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">Pay right now</span>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-trust-blue/10 text-trust-blue">Instant</span>
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Card, Direct Debit, or bank transfer complete it today
</p>
</div>
<span className="text-muted-foreground/40 group-hover:text-trust-blue transition-colors"></span>
</div>
</button>
{/* Pay on a date */}
<button
onClick={() => setMode("calendar")}
className="w-full text-left rounded-2xl border-2 border-success-green/30 bg-gradient-to-r from-success-green/5 to-white p-5 transition-all duration-200 card-hover hover:border-success-green hover:shadow-lg group"
>
<div className="flex items-start gap-4">
<div className="rounded-xl bg-gradient-to-br from-success-green to-emerald-600 p-3 shadow-lg shadow-success-green/20 group-hover:scale-105 transition-transform">
<Calendar className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">Pay on a specific date</span>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-success-green text-white">Most popular</span>
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Choose a date that suits you we&apos;ll send payment details then
</p>
</div>
<span className="text-muted-foreground/40 group-hover:text-success-green transition-colors"></span>
</div>
</button>
{/* Installments */}
<button
onClick={() => setMode("installments")}
className="w-full text-left rounded-2xl border-2 border-gray-200 bg-white p-5 transition-all duration-200 card-hover hover:border-warm-amber/40 hover:shadow-lg group"
>
<div className="flex items-start gap-4">
<div className="rounded-xl bg-gradient-to-br from-warm-amber to-orange-500 p-3 shadow-lg shadow-warm-amber/20 group-hover:scale-105 transition-transform">
<Repeat className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">Split into monthly payments</span>
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Spread your £{pounds} across 212 months easier on the wallet
</p>
</div>
<span className="text-muted-foreground/40 group-hover:text-warm-amber transition-colors"></span>
</div>
</button>
</div>
<p className="text-center text-xs text-muted-foreground">
We&apos;ll send you a reminder with payment details on the day
</p>
</div>
)
}
// === CALENDAR PICKER ===
if (mode === "calendar") {
return (
<div className="max-w-md mx-auto pt-2 space-y-5 animate-fade-up">
<div className="text-center space-y-2">
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
Pick your payment date
</h1>
<p className="text-muted-foreground text-sm">
We&apos;ll send you payment details on this day
</p>
</div>
{/* Smart suggestions */}
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 no-scrollbar">
{smartDates.map((s, i) => {
const isSelected = selectedDate?.toDateString() === s.date.toDateString()
return (
<button
key={i}
onClick={() => setSelectedDate(s.date)}
className={`shrink-0 rounded-xl border-2 px-3.5 py-2 text-left transition-all ${
isSelected
? "border-success-green bg-success-green/5 shadow-md shadow-success-green/10"
: "border-gray-200 bg-white hover:border-success-green/40"
}`}
>
<p className="text-xs font-bold text-gray-900">{s.label}</p>
<p className="text-[10px] text-muted-foreground">{s.subtext}</p>
</button>
)
})}
</div>
{/* Mini calendar */}
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<button onClick={() => setCalendarMonth(m => ({ ...m, month: m.month - 1 < 0 ? 11 : m.month - 1, year: m.month - 1 < 0 ? m.year - 1 : m.year }))}>
<ChevronLeft className="h-5 w-5 text-muted-foreground hover:text-foreground" />
</button>
<span className="font-bold text-sm">
{MONTHS[calendarMonth.month]} {calendarMonth.year}
</span>
<button onClick={() => setCalendarMonth(m => ({ ...m, month: m.month + 1 > 11 ? 0 : m.month + 1, year: m.month + 1 > 11 ? m.year + 1 : m.year }))}>
<ChevronRight className="h-5 w-5 text-muted-foreground hover:text-foreground" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-center">
{DAYS.map(d => (
<div key={d} className="text-[10px] font-medium text-muted-foreground py-1">{d.slice(0, 2)}</div>
))}
{calendarDays.map((day, i) => {
const isSelected = selectedDate?.toDateString() === day.date.toDateString()
const isToday = day.date.toDateString() === new Date().toDateString()
return (
<button
key={i}
onClick={() => !day.isPast && day.inMonth && setSelectedDate(day.date)}
disabled={day.isPast || !day.inMonth}
className={`h-9 w-9 mx-auto rounded-lg text-xs font-medium transition-all ${
isSelected
? "bg-success-green text-white font-bold shadow-md shadow-success-green/30"
: isToday
? "bg-trust-blue/10 text-trust-blue font-bold"
: day.isPast || !day.inMonth
? "text-gray-300 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{day.date.getDate()}
</button>
)
})}
</div>
</CardContent>
</Card>
{/* Selected date summary */}
{selectedDate && (
<div className="rounded-2xl bg-success-green/5 border border-success-green/20 p-4 text-center animate-scale-in">
<p className="text-xs text-muted-foreground">You&apos;ll pay</p>
<p className="text-xl font-black text-gray-900">£{pounds}</p>
<p className="text-sm font-medium text-success-green">{formatFull(selectedDate)}</p>
<p className="text-xs text-muted-foreground mt-1">
We&apos;ll WhatsApp/email you payment details on the day
</p>
</div>
)}
<Button
size="xl"
className={`w-full ${selectedDate ? "bg-success-green hover:bg-success-green/90 shadow-lg shadow-success-green/25" : ""}`}
disabled={!selectedDate}
onClick={handleDateConfirm}
>
{selectedDate ? `Confirm — pay on ${formatShort(selectedDate)}` : "Select a date"}
</Button>
<button onClick={() => setMode("choice")} className="text-sm text-muted-foreground hover:text-foreground transition-colors tap-target flex items-center gap-1 mx-auto">
Back
</button>
</div>
)
}
// === INSTALLMENT PICKER ===
return (
<div className="max-w-md mx-auto pt-2 space-y-5 animate-fade-up">
<div className="text-center space-y-2">
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
Split into payments
</h1>
<p className="text-muted-foreground text-sm">
Spread your £{pounds} pledge across monthly installments
</p>
</div>
{/* Installment count selector */}
<div className="flex gap-2 justify-center flex-wrap">
{[2, 3, 4, 6, 12].map(n => (
<button
key={n}
onClick={() => setInstallmentCount(n)}
className={`rounded-xl border-2 px-4 py-3 text-center transition-all min-w-[70px] ${
installmentCount === n
? "border-warm-amber bg-warm-amber text-white font-bold shadow-lg shadow-warm-amber/25"
: "border-gray-200 bg-white hover:border-warm-amber/40"
}`}
>
<p className="text-lg font-bold">{n}</p>
<p className="text-[10px]">{n === 12 ? "months" : "months"}</p>
</button>
))}
</div>
{/* Breakdown */}
<Card className="overflow-hidden">
<div className="h-1 bg-gradient-to-r from-warm-amber to-orange-400" />
<CardContent className="pt-4">
<div className="text-center mb-4">
<p className="text-xs text-muted-foreground">Monthly payment</p>
<p className="text-4xl font-black text-warm-amber">
£{(perInstallment / 100).toFixed(0)}
</p>
<p className="text-xs text-muted-foreground">
× {installmentCount} months = £{(perInstallment * installmentCount / 100).toFixed(0)}
{perInstallment * installmentCount !== amount && (
<span className="text-[10px] ml-1">(rounding: +£{((perInstallment * installmentCount - amount) / 100).toFixed(2)})</span>
)}
</p>
</div>
<div className="space-y-2">
{installmentDates.map((d, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold ${
i === 0 ? "bg-warm-amber text-white" : "bg-gray-200 text-gray-500"
}`}>
{i + 1}
</div>
<span className="text-sm">{formatFull(d)}</span>
</div>
<span className="font-bold text-sm">£{(perInstallment / 100).toFixed(0)}</span>
</div>
))}
</div>
</CardContent>
</Card>
<div className="rounded-xl bg-warm-amber/5 border border-warm-amber/20 p-3 text-center">
<p className="text-xs text-muted-foreground">
We&apos;ll send you a reminder with payment details before each installment date.
You can pay via bank transfer, card, or Direct Debit.
</p>
</div>
<Button
size="xl"
className="w-full bg-warm-amber hover:bg-warm-amber/90 shadow-lg shadow-warm-amber/25"
onClick={handleInstallmentConfirm}
>
<Check className="h-5 w-5 mr-2" />
Confirm {installmentCount}× £{(perInstallment / 100).toFixed(0)}/month
</Button>
<button onClick={() => setMode("choice")} className="text-sm text-muted-foreground hover:text-foreground transition-colors tap-target flex items-center gap-1 mx-auto">
Back
</button>
</div>
)
}