diff --git a/pledge-now-pay-later/prisma/migrations/20260303_payment_scheduling/migration.sql b/pledge-now-pay-later/prisma/migrations/20260303_payment_scheduling/migration.sql new file mode 100644 index 0000000..e245144 --- /dev/null +++ b/pledge-now-pay-later/prisma/migrations/20260303_payment_scheduling/migration.sql @@ -0,0 +1,10 @@ +-- Payment scheduling: pledges can have a future due date and installment plans +ALTER TABLE "Pledge" ADD COLUMN "dueDate" TIMESTAMP(3); +ALTER TABLE "Pledge" ADD COLUMN "planId" TEXT; +ALTER TABLE "Pledge" ADD COLUMN "installmentNumber" INTEGER; +ALTER TABLE "Pledge" ADD COLUMN "installmentTotal" INTEGER; +ALTER TABLE "Pledge" ADD COLUMN "reminderSentForDueDate" BOOLEAN NOT NULL DEFAULT false; + +-- Index for finding pledges due today/upcoming +CREATE INDEX "Pledge_dueDate_status_idx" ON "Pledge"("dueDate", "status"); +CREATE INDEX "Pledge_planId_idx" ON "Pledge"("planId"); diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma index 7a7aafc..f6a4b47 100644 --- a/pledge-now-pay-later/prisma/schema.prisma +++ b/pledge-now-pay-later/prisma/schema.prisma @@ -100,6 +100,13 @@ model Pledge { iPaidClickedAt DateTime? notes String? + // 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 + installmentNumber Int? // e.g. 1 (of 4) + installmentTotal Int? // e.g. 4 + reminderSentForDueDate Boolean @default(false) + eventId String event Event @relation(fields: [eventId], references: [id]) qrSourceId String? @@ -121,6 +128,8 @@ model Pledge { @@index([eventId, status]) @@index([donorEmail]) @@index([donorPhone]) + @@index([dueDate, status]) + @@index([planId]) } model PaymentInstruction { diff --git a/pledge-now-pay-later/src/app/api/dashboard/route.ts b/pledge-now-pay-later/src/app/api/dashboard/route.ts index 25c0b46..af63b92 100644 --- a/pledge-now-pay-later/src/app/api/dashboard/route.ts +++ b/pledge-now-pay-later/src/app/api/dashboard/route.ts @@ -12,6 +12,10 @@ interface PledgeRow { donorEmail: string | null donorPhone: string | null giftAid: boolean + dueDate: Date | null + planId: string | null + installmentNumber: number | null + installmentTotal: number | null createdAt: Date paidAt: Date | null event: { name: string } @@ -139,6 +143,11 @@ export async function GET(request: NextRequest) { source: p.qrSource?.label || null, volunteerName: p.qrSource?.volunteerName || null, giftAid: p.giftAid, + dueDate: p.dueDate, + planId: p.planId, + installmentNumber: p.installmentNumber, + installmentTotal: p.installmentTotal, + isDeferred: !!p.dueDate, createdAt: p.createdAt, paidAt: p.paidAt, nextReminder: p.reminders diff --git a/pledge-now-pay-later/src/app/api/pledges/route.ts b/pledge-now-pay-later/src/app/api/pledges/route.ts index 7b95fac..702efda 100644 --- a/pledge-now-pay-later/src/app/api/pledges/route.ts +++ b/pledge-now-pay-later/src/app/api/pledges/route.ts @@ -21,7 +21,7 @@ export async function POST(request: NextRequest) { ) } - const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = parsed.data + const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data // Get event + org const event = await prisma.event.findUnique({ @@ -35,6 +35,80 @@ export async function POST(request: NextRequest) { const org = event.organization + // --- INSTALLMENT MODE: create N linked pledges --- + if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && installmentDates?.length) { + const perInstallment = Math.ceil(amountPence / installmentCount) + const planId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + let firstRef = "" + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await prisma.$transaction(async (tx: any) => { + for (let i = 0; i < installmentCount; i++) { + let ref = "" + let attempts = 0 + while (attempts < 10) { + ref = generateReference(org.refPrefix || "PNPL", perInstallment) + const exists = await tx.pledge.findUnique({ where: { reference: ref } }) + if (!exists) break + attempts++ + } + if (i === 0) firstRef = ref + + const installmentDue = new Date(installmentDates[i]) + + const p = await tx.pledge.create({ + data: { + reference: ref, + amountPence: perInstallment, + currency: "GBP", + rail, + status: "new", + donorName: donorName || null, + donorEmail: donorEmail || null, + donorPhone: donorPhone || null, + giftAid, + eventId, + qrSourceId: qrSourceId || null, + organizationId: org.id, + dueDate: installmentDue, + planId, + installmentNumber: i + 1, + installmentTotal: installmentCount, + }, + }) + + // Reminders scheduled relative to due date (2 days before, on day, 2 days after, 7 days after) + const dueDateMs = installmentDue.getTime() + await tx.reminder.createMany({ + data: [ + { pledgeId: p.id, step: 0, channel: "whatsapp", scheduledAt: new Date(dueDateMs - 2 * 86400000), status: "pending", payload: { templateKey: "upcoming_installment" } }, + { pledgeId: p.id, step: 1, channel: "whatsapp", scheduledAt: installmentDue, status: "pending", payload: { templateKey: "installment_due" } }, + { pledgeId: p.id, step: 2, channel: "email", scheduledAt: new Date(dueDateMs + 2 * 86400000), status: "pending", payload: { templateKey: "gentle_nudge" } }, + { pledgeId: p.id, step: 3, channel: "email", scheduledAt: new Date(dueDateMs + 7 * 86400000), status: "pending", payload: { templateKey: "urgency_impact" } }, + ], + }) + + await tx.analyticsEvent.create({ + data: { eventType: "pledge_completed", pledgeId: p.id, eventId, qrSourceId: qrSourceId || null, metadata: { amountPence: perInstallment, rail, installment: i + 1, of: installmentCount, planId } }, + }) + } + }) + + // WhatsApp receipt for the plan + if (donorPhone) { + const name = donorName?.split(" ")[0] || "there" + const { sendWhatsAppMessage } = await import("@/lib/whatsapp") + sendWhatsAppMessage(donorPhone, + `🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\nšŸ’· *Ā£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\nšŸ“† *${installmentCount} monthly payments* of *Ā£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(installmentDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.` + ).catch(err => console.error("[WAHA] Installment receipt failed:", err)) + } + + return NextResponse.json({ id: planId, reference: firstRef }, { status: 201 }) + } + + // --- SINGLE PLEDGE (immediate or deferred) --- + const parsedDueDate = scheduleMode === "date" && dueDate ? new Date(dueDate) : null + // Generate unique reference (retry on collision) let reference = "" let attempts = 0 @@ -65,6 +139,7 @@ export async function POST(request: NextRequest) { eventId, qrSourceId: qrSourceId || null, organizationId: org.id, + dueDate: parsedDueDate, }, }) @@ -84,18 +159,32 @@ export async function POST(request: NextRequest) { }) } - // Create reminder schedule - const schedule = calculateReminderSchedule(new Date()) - await tx.reminder.createMany({ - data: schedule.map((s) => ({ - pledgeId: p.id, - step: s.step, - channel: s.channel, - scheduledAt: s.scheduledAt, - status: "pending", - payload: { templateKey: s.templateKey, subject: s.subject }, - })), - }) + // Create reminder schedule — based on due date for deferred, or now for immediate + if (parsedDueDate) { + // DEFERRED: reminders relative to due date + const dueDateMs = parsedDueDate.getTime() + await tx.reminder.createMany({ + data: [ + { pledgeId: p.id, step: 0, channel: donorPhone ? "whatsapp" : "email", scheduledAt: new Date(dueDateMs - 2 * 86400000), status: "pending", payload: { templateKey: "upcoming_payment", subject: "Payment reminder — 2 days to go" } }, + { pledgeId: p.id, step: 1, channel: donorPhone ? "whatsapp" : "email", scheduledAt: parsedDueDate, status: "pending", payload: { templateKey: "payment_due_today", subject: "Your payment is due today" } }, + { pledgeId: p.id, step: 2, channel: "email", scheduledAt: new Date(dueDateMs + 3 * 86400000), status: "pending", payload: { templateKey: "gentle_nudge", subject: "Quick reminder about your pledge" } }, + { pledgeId: p.id, step: 3, channel: "email", scheduledAt: new Date(dueDateMs + 10 * 86400000), status: "pending", payload: { templateKey: "final_reminder", subject: "Final reminder about your pledge" } }, + ], + }) + } else { + // IMMEDIATE: reminders from now + const schedule = calculateReminderSchedule(new Date()) + await tx.reminder.createMany({ + data: schedule.map((s) => ({ + pledgeId: p.id, + step: s.step, + channel: donorPhone ? "whatsapp" : s.channel, // prefer WhatsApp if phone given + scheduledAt: s.scheduledAt, + status: "pending", + payload: { templateKey: s.templateKey, subject: s.subject }, + })), + }) + } // Track analytics await tx.analyticsEvent.create({ diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx index 95c081b..64bd471 100644 --- a/pledge-now-pay-later/src/app/p/[token]/page.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx @@ -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 = { 0: , - 1: , - 2: , - 3: pledgeResult && , - 4: pledgeResult && , - 5: , - 7: , + 1: , + 2: , + 3: , + 4: pledgeResult && , + 5: pledgeResult && ( + + ), + 6: , + 8: , } - 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 = { 0: 8, 1: 33, 2: 55, 3: 100, 4: 100, 5: 55, 7: 55 } + const progressMap: Record = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 8: 60 } const progressPercent = progressMap[step] || 10 return ( diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx index 5dc5c63..6757b9c 100644 --- a/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/steps/confirmation-step.tsx @@ -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 = { - 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

- {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!"}

Thank you for your generous support of{" "} @@ -155,7 +167,18 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do {copied ? : }

- {rail === "card" && ( + {isDeferred && dueDateLabel && ( +
+ {installmentCount && installmentCount > 1 ? "Schedule" : "Payment Date"} + + {installmentCount && installmentCount > 1 + ? `${installmentCount}Ɨ Ā£${((installmentAmount || 0) / 100).toFixed(0)}/mo` + : dueDateLabel + } + +
+ )} + {rail === "card" && !isDeferred && (
Status diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/schedule-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/schedule-step.tsx new file mode 100644 index 0000000..f3ee320 --- /dev/null +++ b/pledge-now-pay-later/src/app/p/[token]/steps/schedule-step.tsx @@ -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(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 ( +
+
+

+ When would you like to pay? +

+

+ Your Ā£{pounds} pledge — choose what works for you +

+
+ +
+ {/* Pay Now */} + + + {/* Pay on a date */} + + + {/* Installments */} + +
+ +

+ We'll send you a reminder with payment details on the day +

+
+ ) + } + + // === CALENDAR PICKER === + if (mode === "calendar") { + return ( +
+
+

+ Pick your payment date +

+

+ We'll send you payment details on this day +

+
+ + {/* Smart suggestions */} +
+ {smartDates.map((s, i) => { + const isSelected = selectedDate?.toDateString() === s.date.toDateString() + return ( + + ) + })} +
+ + {/* Mini calendar */} + + +
+ + + {MONTHS[calendarMonth.month]} {calendarMonth.year} + + +
+ +
+ {DAYS.map(d => ( +
{d.slice(0, 2)}
+ ))} + {calendarDays.map((day, i) => { + const isSelected = selectedDate?.toDateString() === day.date.toDateString() + const isToday = day.date.toDateString() === new Date().toDateString() + return ( + + ) + })} +
+
+
+ + {/* Selected date summary */} + {selectedDate && ( +
+

You'll pay

+

Ā£{pounds}

+

{formatFull(selectedDate)}

+

+ We'll WhatsApp/email you payment details on the day +

+
+ )} + + + + +
+ ) + } + + // === INSTALLMENT PICKER === + return ( +
+
+

+ Split into payments +

+

+ Spread your £{pounds} pledge across monthly installments +

+
+ + {/* Installment count selector */} +
+ {[2, 3, 4, 6, 12].map(n => ( + + ))} +
+ + {/* Breakdown */} + +
+ +
+

Monthly payment

+

+ £{(perInstallment / 100).toFixed(0)} +

+

+ Ɨ {installmentCount} months = Ā£{(perInstallment * installmentCount / 100).toFixed(0)} + {perInstallment * installmentCount !== amount && ( + (rounding: +Ā£{((perInstallment * installmentCount - amount) / 100).toFixed(2)}) + )} +

+
+ +
+ {installmentDates.map((d, i) => ( +
+
+
+ {i + 1} +
+ {formatFull(d)} +
+ £{(perInstallment / 100).toFixed(0)} +
+ ))} +
+
+ + +
+

+ We'll send you a reminder with payment details before each installment date. + You can pay via bank transfer, card, or Direct Debit. +

+
+ + + + +
+ ) +} diff --git a/pledge-now-pay-later/src/lib/validators.ts b/pledge-now-pay-later/src/lib/validators.ts index 0a7124f..45f49f0 100644 --- a/pledge-now-pay-later/src/lib/validators.ts +++ b/pledge-now-pay-later/src/lib/validators.ts @@ -17,13 +17,18 @@ export const createQrSourceSchema = z.object({ export const createPledgeSchema = z.object({ amountPence: z.number().int().min(100).max(100000000), // £1 to £1M - rail: z.enum(['bank', 'gocardless', 'card', 'fpx']), + rail: z.enum(['bank', 'gocardless', 'card']), donorName: z.string().max(200).optional().default(''), donorEmail: z.string().max(200).optional().default(''), donorPhone: z.string().max(20).optional().default(''), giftAid: z.boolean().default(false), eventId: z.string(), qrSourceId: z.string().nullable().optional(), + // Payment scheduling + scheduleMode: z.enum(['now', 'date', 'installments']).default('now'), + dueDate: z.string().optional(), + installmentCount: z.number().int().min(2).max(12).optional(), + installmentDates: z.array(z.string()).optional(), }).transform((data) => ({ ...data, donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined,