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:
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { AmountStep } from "./steps/amount-step"
|
||||
import { ScheduleStep } from "./steps/schedule-step"
|
||||
import { PaymentStep } from "./steps/payment-step"
|
||||
import { IdentityStep } from "./steps/identity-step"
|
||||
import { ConfirmationStep } from "./steps/confirmation-step"
|
||||
@@ -19,6 +20,11 @@ export interface PledgeData {
|
||||
donorEmail: string
|
||||
donorPhone: string
|
||||
giftAid: boolean
|
||||
// Scheduling
|
||||
scheduleMode: "now" | "date" | "installments"
|
||||
dueDate?: string
|
||||
installmentCount?: number
|
||||
installmentDates?: string[]
|
||||
}
|
||||
|
||||
interface EventInfo {
|
||||
@@ -29,6 +35,18 @@ interface EventInfo {
|
||||
qrSourceLabel: string | null
|
||||
}
|
||||
|
||||
/*
|
||||
Flow:
|
||||
0 = Amount
|
||||
1 = Schedule (When to pay?) ← NEW
|
||||
2 = Payment method (if "now") or Identity (if deferred)
|
||||
3 = Identity (for bank transfer "now")
|
||||
4 = Bank instructions (now)
|
||||
5 = Confirmation (generic — card, DD, or deferred pledge)
|
||||
6 = Card payment
|
||||
8 = Direct Debit
|
||||
*/
|
||||
|
||||
export default function PledgePage() {
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
@@ -41,6 +59,7 @@ export default function PledgePage() {
|
||||
donorEmail: "",
|
||||
donorPhone: "",
|
||||
giftAid: false,
|
||||
scheduleMode: "now",
|
||||
})
|
||||
const [pledgeResult, setPledgeResult] = useState<{
|
||||
id: string
|
||||
@@ -66,26 +85,44 @@ export default function PledgePage() {
|
||||
}).catch(() => {})
|
||||
}, [token])
|
||||
|
||||
// Step 0: Amount selected
|
||||
const handleAmountSelected = (amountPence: number) => {
|
||||
setPledgeData((d) => ({ ...d, amountPence }))
|
||||
setStep(1)
|
||||
fetch("/api/analytics", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventType: "amount_selected", metadata: { amountPence, token } }),
|
||||
}).catch(() => {})
|
||||
setStep(1) // → Schedule step
|
||||
}
|
||||
|
||||
// Step 1: Schedule selected
|
||||
const handleScheduleSelected = (schedule: {
|
||||
mode: "now" | "date" | "installments"
|
||||
dueDate?: string
|
||||
installmentCount?: number
|
||||
installmentDates?: string[]
|
||||
}) => {
|
||||
setPledgeData((d) => ({
|
||||
...d,
|
||||
scheduleMode: schedule.mode,
|
||||
dueDate: schedule.dueDate,
|
||||
installmentCount: schedule.installmentCount,
|
||||
installmentDates: schedule.installmentDates,
|
||||
}))
|
||||
|
||||
if (schedule.mode === "now") {
|
||||
setStep(2) // → Payment method selection
|
||||
} else {
|
||||
// Deferred or installments: skip payment method, go to identity
|
||||
// Payment method will be chosen when the due date arrives
|
||||
setPledgeData((d) => ({ ...d, rail: "bank" })) // default to bank for deferred
|
||||
setStep(3) // → Identity
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Payment method selected (only for "now" mode)
|
||||
const handleRailSelected = (rail: Rail) => {
|
||||
setPledgeData((d) => ({ ...d, rail }))
|
||||
fetch("/api/analytics", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventType: "rail_selected", metadata: { rail, token } }),
|
||||
}).catch(() => {})
|
||||
setStep(rail === "bank" ? 2 : rail === "card" ? 5 : 7)
|
||||
setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8) // identity or card/DD
|
||||
}
|
||||
|
||||
// Submit pledge (from identity step, or card/DD steps)
|
||||
const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean }) => {
|
||||
const finalData = { ...pledgeData, ...identity }
|
||||
setPledgeData(finalData)
|
||||
@@ -94,12 +131,22 @@ export default function PledgePage() {
|
||||
const res = await fetch("/api/pledges", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...finalData, eventId: eventInfo?.id, qrSourceId: eventInfo?.qrSourceId }),
|
||||
body: JSON.stringify({
|
||||
...finalData,
|
||||
eventId: eventInfo?.id,
|
||||
qrSourceId: eventInfo?.qrSourceId,
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) { setError(result.error); return }
|
||||
setPledgeResult(result)
|
||||
setStep(finalData.rail === "bank" ? 3 : 4)
|
||||
|
||||
// Where to go after pledge is created:
|
||||
if (finalData.scheduleMode === "now" && finalData.rail === "bank") {
|
||||
setStep(4) // Bank instructions
|
||||
} else {
|
||||
setStep(5) // Confirmation
|
||||
}
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.")
|
||||
}
|
||||
@@ -132,22 +179,48 @@ export default function PledgePage() {
|
||||
}
|
||||
|
||||
const shareUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}`
|
||||
const isDeferred = pledgeData.scheduleMode !== "now"
|
||||
|
||||
// Format due date for display
|
||||
const dueDateLabel = pledgeData.dueDate
|
||||
? new Date(pledgeData.dueDate).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" })
|
||||
: pledgeData.installmentCount
|
||||
? `${pledgeData.installmentCount} monthly payments`
|
||||
: undefined
|
||||
|
||||
const steps: Record<number, React.ReactNode> = {
|
||||
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
|
||||
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
||||
2: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
|
||||
3: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
||||
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} shareUrl={shareUrl} donorPhone={pledgeData.donorPhone} />,
|
||||
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||
7: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
||||
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
||||
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
|
||||
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
||||
5: pledgeResult && (
|
||||
<ConfirmationStep
|
||||
pledge={pledgeResult}
|
||||
amount={isDeferred && pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) * pledgeData.installmentCount : pledgeData.amountPence}
|
||||
rail={pledgeData.rail}
|
||||
eventName={eventInfo?.name || ""}
|
||||
shareUrl={shareUrl}
|
||||
donorPhone={pledgeData.donorPhone}
|
||||
isDeferred={isDeferred}
|
||||
dueDateLabel={dueDateLabel}
|
||||
installmentCount={pledgeData.installmentCount}
|
||||
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
|
||||
/>
|
||||
),
|
||||
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||
8: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||
}
|
||||
|
||||
const backableSteps = new Set([1, 2, 5, 7])
|
||||
const getBackStep = (s: number): number => (s === 5 || s === 7) ? 1 : s - 1
|
||||
const backableSteps = new Set([1, 2, 3, 6, 8])
|
||||
const getBackStep = (s: number): number => {
|
||||
if (s === 6 || s === 8) return 2 // card/DD → payment method
|
||||
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
|
||||
if (s === 3) return 2 // bank identity → payment method
|
||||
return s - 1
|
||||
}
|
||||
|
||||
// Smooth progress
|
||||
const progressMap: Record<number, number> = { 0: 8, 1: 33, 2: 55, 3: 100, 4: 100, 5: 55, 7: 55 }
|
||||
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 8: 60 }
|
||||
const progressPercent = progressMap[step] || 10
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user