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:
@@ -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");
|
||||||
@@ -100,6 +100,13 @@ model Pledge {
|
|||||||
iPaidClickedAt DateTime?
|
iPaidClickedAt DateTime?
|
||||||
notes String?
|
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
|
eventId String
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
event Event @relation(fields: [eventId], references: [id])
|
||||||
qrSourceId String?
|
qrSourceId String?
|
||||||
@@ -121,6 +128,8 @@ model Pledge {
|
|||||||
@@index([eventId, status])
|
@@index([eventId, status])
|
||||||
@@index([donorEmail])
|
@@index([donorEmail])
|
||||||
@@index([donorPhone])
|
@@index([donorPhone])
|
||||||
|
@@index([dueDate, status])
|
||||||
|
@@index([planId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PaymentInstruction {
|
model PaymentInstruction {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ interface PledgeRow {
|
|||||||
donorEmail: string | null
|
donorEmail: string | null
|
||||||
donorPhone: string | null
|
donorPhone: string | null
|
||||||
giftAid: boolean
|
giftAid: boolean
|
||||||
|
dueDate: Date | null
|
||||||
|
planId: string | null
|
||||||
|
installmentNumber: number | null
|
||||||
|
installmentTotal: number | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
paidAt: Date | null
|
paidAt: Date | null
|
||||||
event: { name: string }
|
event: { name: string }
|
||||||
@@ -139,6 +143,11 @@ export async function GET(request: NextRequest) {
|
|||||||
source: p.qrSource?.label || null,
|
source: p.qrSource?.label || null,
|
||||||
volunteerName: p.qrSource?.volunteerName || null,
|
volunteerName: p.qrSource?.volunteerName || null,
|
||||||
giftAid: p.giftAid,
|
giftAid: p.giftAid,
|
||||||
|
dueDate: p.dueDate,
|
||||||
|
planId: p.planId,
|
||||||
|
installmentNumber: p.installmentNumber,
|
||||||
|
installmentTotal: p.installmentTotal,
|
||||||
|
isDeferred: !!p.dueDate,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
paidAt: p.paidAt,
|
paidAt: p.paidAt,
|
||||||
nextReminder: p.reminders
|
nextReminder: p.reminders
|
||||||
|
|||||||
@@ -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
|
// Get event + org
|
||||||
const event = await prisma.event.findUnique({
|
const event = await prisma.event.findUnique({
|
||||||
@@ -35,6 +35,80 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const org = event.organization
|
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)
|
// Generate unique reference (retry on collision)
|
||||||
let reference = ""
|
let reference = ""
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
@@ -65,6 +139,7 @@ export async function POST(request: NextRequest) {
|
|||||||
eventId,
|
eventId,
|
||||||
qrSourceId: qrSourceId || null,
|
qrSourceId: qrSourceId || null,
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
|
dueDate: parsedDueDate,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,18 +159,32 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create reminder schedule
|
// 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())
|
const schedule = calculateReminderSchedule(new Date())
|
||||||
await tx.reminder.createMany({
|
await tx.reminder.createMany({
|
||||||
data: schedule.map((s) => ({
|
data: schedule.map((s) => ({
|
||||||
pledgeId: p.id,
|
pledgeId: p.id,
|
||||||
step: s.step,
|
step: s.step,
|
||||||
channel: s.channel,
|
channel: donorPhone ? "whatsapp" : s.channel, // prefer WhatsApp if phone given
|
||||||
scheduledAt: s.scheduledAt,
|
scheduledAt: s.scheduledAt,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
payload: { templateKey: s.templateKey, subject: s.subject },
|
payload: { templateKey: s.templateKey, subject: s.subject },
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Track analytics
|
// Track analytics
|
||||||
await tx.analyticsEvent.create({
|
await tx.analyticsEvent.create({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
import { AmountStep } from "./steps/amount-step"
|
import { AmountStep } from "./steps/amount-step"
|
||||||
|
import { ScheduleStep } from "./steps/schedule-step"
|
||||||
import { PaymentStep } from "./steps/payment-step"
|
import { PaymentStep } from "./steps/payment-step"
|
||||||
import { IdentityStep } from "./steps/identity-step"
|
import { IdentityStep } from "./steps/identity-step"
|
||||||
import { ConfirmationStep } from "./steps/confirmation-step"
|
import { ConfirmationStep } from "./steps/confirmation-step"
|
||||||
@@ -19,6 +20,11 @@ export interface PledgeData {
|
|||||||
donorEmail: string
|
donorEmail: string
|
||||||
donorPhone: string
|
donorPhone: string
|
||||||
giftAid: boolean
|
giftAid: boolean
|
||||||
|
// Scheduling
|
||||||
|
scheduleMode: "now" | "date" | "installments"
|
||||||
|
dueDate?: string
|
||||||
|
installmentCount?: number
|
||||||
|
installmentDates?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventInfo {
|
interface EventInfo {
|
||||||
@@ -29,6 +35,18 @@ interface EventInfo {
|
|||||||
qrSourceLabel: string | null
|
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() {
|
export default function PledgePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const token = params.token as string
|
const token = params.token as string
|
||||||
@@ -41,6 +59,7 @@ export default function PledgePage() {
|
|||||||
donorEmail: "",
|
donorEmail: "",
|
||||||
donorPhone: "",
|
donorPhone: "",
|
||||||
giftAid: false,
|
giftAid: false,
|
||||||
|
scheduleMode: "now",
|
||||||
})
|
})
|
||||||
const [pledgeResult, setPledgeResult] = useState<{
|
const [pledgeResult, setPledgeResult] = useState<{
|
||||||
id: string
|
id: string
|
||||||
@@ -66,26 +85,44 @@ export default function PledgePage() {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
|
// Step 0: Amount selected
|
||||||
const handleAmountSelected = (amountPence: number) => {
|
const handleAmountSelected = (amountPence: number) => {
|
||||||
setPledgeData((d) => ({ ...d, amountPence }))
|
setPledgeData((d) => ({ ...d, amountPence }))
|
||||||
setStep(1)
|
setStep(1) // → Schedule step
|
||||||
fetch("/api/analytics", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ eventType: "amount_selected", metadata: { amountPence, token } }),
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
const handleRailSelected = (rail: Rail) => {
|
||||||
setPledgeData((d) => ({ ...d, rail }))
|
setPledgeData((d) => ({ ...d, rail }))
|
||||||
fetch("/api/analytics", {
|
setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8) // identity or card/DD
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Submit pledge (from identity step, or card/DD steps)
|
||||||
const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean }) => {
|
const submitPledge = async (identity: { donorName: string; donorEmail: string; donorPhone: string; giftAid: boolean }) => {
|
||||||
const finalData = { ...pledgeData, ...identity }
|
const finalData = { ...pledgeData, ...identity }
|
||||||
setPledgeData(finalData)
|
setPledgeData(finalData)
|
||||||
@@ -94,12 +131,22 @@ export default function PledgePage() {
|
|||||||
const res = await fetch("/api/pledges", {
|
const res = await fetch("/api/pledges", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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()
|
const result = await res.json()
|
||||||
if (result.error) { setError(result.error); return }
|
if (result.error) { setError(result.error); return }
|
||||||
setPledgeResult(result)
|
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 {
|
} catch {
|
||||||
setError("Something went wrong. Please try again.")
|
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 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> = {
|
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} />,
|
||||||
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
||||||
2: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
|
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
||||||
3: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
|
||||||
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} shareUrl={shareUrl} donorPhone={pledgeData.donorPhone} />,
|
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
||||||
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
5: pledgeResult && (
|
||||||
7: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
<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 backableSteps = new Set([1, 2, 3, 6, 8])
|
||||||
const getBackStep = (s: number): number => (s === 5 || s === 7) ? 1 : s - 1
|
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: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 8: 60 }
|
||||||
const progressMap: Record<number, number> = { 0: 8, 1: 33, 2: 55, 3: 100, 4: 100, 5: 55, 7: 55 }
|
|
||||||
const progressPercent = progressMap[step] || 10
|
const progressPercent = progressMap[step] || 10
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ interface Props {
|
|||||||
eventName: string
|
eventName: string
|
||||||
shareUrl?: string
|
shareUrl?: string
|
||||||
donorPhone?: string
|
donorPhone?: string
|
||||||
|
isDeferred?: boolean
|
||||||
|
dueDateLabel?: string
|
||||||
|
installmentCount?: number
|
||||||
|
installmentAmount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini confetti
|
// 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 [copied, setCopied] = useState(false)
|
||||||
const [whatsappSent, setWhatsappSent] = useState(false)
|
const [whatsappSent, setWhatsappSent] = useState(false)
|
||||||
|
|
||||||
@@ -60,8 +64,14 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
card: "Card Payment",
|
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> = {
|
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.`,
|
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.",
|
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">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-black text-gray-900">
|
<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>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Thank you for your generous support of{" "}
|
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" />}
|
{copied ? <Check className="h-3.5 w-3.5 text-success-green" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between items-center pt-1 border-t">
|
||||||
<span className="text-muted-foreground">Status</span>
|
<span className="text-muted-foreground">Status</span>
|
||||||
<span className="text-success-green font-bold flex items-center gap-1">
|
<span className="text-success-green font-bold flex items-center gap-1">
|
||||||
|
|||||||
416
pledge-now-pay-later/src/app/p/[token]/steps/schedule-step.tsx
Normal file
416
pledge-now-pay-later/src/app/p/[token]/steps/schedule-step.tsx
Normal 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'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 2–12 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'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'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'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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,13 +17,18 @@ export const createQrSourceSchema = z.object({
|
|||||||
|
|
||||||
export const createPledgeSchema = 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), // £1 to £1M
|
||||||
rail: z.enum(['bank', 'gocardless', 'card', 'fpx']),
|
rail: z.enum(['bank', 'gocardless', 'card']),
|
||||||
donorName: z.string().max(200).optional().default(''),
|
donorName: z.string().max(200).optional().default(''),
|
||||||
donorEmail: z.string().max(200).optional().default(''),
|
donorEmail: z.string().max(200).optional().default(''),
|
||||||
donorPhone: z.string().max(20).optional().default(''),
|
donorPhone: z.string().max(20).optional().default(''),
|
||||||
giftAid: z.boolean().default(false),
|
giftAid: z.boolean().default(false),
|
||||||
eventId: z.string(),
|
eventId: z.string(),
|
||||||
qrSourceId: z.string().nullable().optional(),
|
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) => ({
|
}).transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined,
|
donorEmail: data.donorEmail && data.donorEmail.includes('@') ? data.donorEmail : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user