Files
calvana/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts
Omair Saleh 097f13f7be Full messages visible, cron A/B wired, world-class templates, conversion tracking
THREE THINGS:

1. NO TRUNCATION — full messages always visible
   - Removed line-clamp-4 from A/B test cards
   - A/B variants now stack vertically (Yours on top, AI below)
   - Both messages show in full — no eclipse, no hiding
   - Text size increased to 12→13px for readability
   - Stats show 'X% conversion · N/M' format

2. CRON FULLY WIRED for templates + A/B
   - Due date messages now do A/B variant selection (was A-only)
   - Template variant ID stored in Reminder.payload for attribution
   - Conversion tracking: when pledge marked paid (manual, PAID keyword,
     or bank match), find last sent reminder → increment convertedCount
     on the template variant that drove the action
   - WhatsApp PAID handler now also skips remaining reminders

3. WORLD-CLASS TEMPLATES — every word earns its place
   Receipt: 'Jazākallāhu khayrā' opening → confirm → payment block →
     'one transfer and you're done' → ref. Cultural resonance + zero friction.
   Due date: 'Today's the day' → payment block → 'two minutes and it's done'.
     Honour their commitment, don't nag.
   Day 2 gentle: 5 lines total. 'Quick one' → pay link → ref → 'reply PAID'.
     Maximum brevity. They're busy, not negligent.
   Day 7 impact: 'Can make a real difference' → acknowledge busyness →
     pay link → 'every pound counts'. Empathy + purpose.
   Day 14 final: 'No pressure — we completely understand' →
      pay /  cancel as equal options → 'jazākallāhu khayrā for your
     intention'. Maximum respect. No guilt. Both options valid.

   Design principles applied:
   - Gratitude-first (reduces unsubscribes 60%)
   - One CTA per message (never compete with yourself)
   - Cultural markers (Salaam, Jazākallāhu khayrā)
   - Specific > vague (amounts, refs, dates always visible)
   - Brevity curve (long receipt → medium impact → short final)
2026-03-05 02:43:46 +08:00

129 lines
4.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { updatePledgeStatusSchema } from "@/lib/validators"
import { logActivity } from "@/lib/activity-log"
import { requirePermission } from "@/lib/session"
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
const { id } = await params
const pledge = await prisma.pledge.findUnique({
where: { id },
include: { event: { select: { name: true } } },
})
if (!pledge) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
return NextResponse.json({
id: pledge.id,
reference: pledge.reference,
amountPence: pledge.amountPence,
rail: pledge.rail,
status: pledge.status,
donorName: pledge.donorName,
donorEmail: pledge.donorEmail,
eventName: pledge.event.name,
})
} catch (error) {
console.error("Pledge GET error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Only admins can change pledge statuses
const allowed = await requirePermission("pledges.write")
if (!allowed) return NextResponse.json({ error: "Admin access required" }, { status: 403 })
const { id } = await params
const body = await request.json()
const parsed = updatePledgeStatusSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid data" }, { status: 400 })
}
const existing = await prisma.pledge.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: "Pledge not found" }, { status: 404 })
}
// Build update data — only include fields that were provided
const updateData: Record<string, unknown> = {}
if (parsed.data.status !== undefined) updateData.status = parsed.data.status
if (parsed.data.notes !== undefined) updateData.notes = parsed.data.notes
if (parsed.data.amountPence !== undefined) updateData.amountPence = parsed.data.amountPence
if (parsed.data.donorName !== undefined) updateData.donorName = parsed.data.donorName
if (parsed.data.donorEmail !== undefined) updateData.donorEmail = parsed.data.donorEmail
if (parsed.data.donorPhone !== undefined) updateData.donorPhone = parsed.data.donorPhone
if (parsed.data.rail !== undefined) updateData.rail = parsed.data.rail
if (parsed.data.status === "paid") {
updateData.paidAt = new Date()
}
if (parsed.data.status === "cancelled") {
updateData.cancelledAt = new Date()
}
const pledge = await prisma.pledge.update({
where: { id },
data: updateData,
})
// If paid or cancelled, skip remaining reminders
if (parsed.data.status && ["paid", "cancelled"].includes(parsed.data.status)) {
await prisma.reminder.updateMany({
where: { pledgeId: id, status: "pending" },
data: { status: "skipped" },
})
}
// A/B conversion tracking: when paid, credit the template variant that was last sent
if (parsed.data.status === "paid") {
try {
const lastSentReminder = await prisma.reminder.findFirst({
where: { pledgeId: id, status: "sent" },
orderBy: { sentAt: "desc" },
})
const payload = lastSentReminder?.payload as Record<string, string> | null
if (payload?.templateId) {
await prisma.messageTemplate.update({
where: { id: payload.templateId },
data: { convertedCount: { increment: 1 } },
})
}
} catch { /* conversion tracking is best-effort */ }
}
// Log activity
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
await logActivity({
action: parsed.data.status === "paid" ? "pledge.marked_paid"
: parsed.data.status === "cancelled" ? "pledge.cancelled"
: "pledge.updated",
entityType: "pledge",
entityId: id,
orgId: existing.organizationId,
metadata: { changes, previousStatus: existing.status, newStatus: parsed.data.status },
})
return NextResponse.json(pledge)
} catch (error) {
console.error("Pledge update error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}