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)
129 lines
4.6 KiB
TypeScript
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 })
|
|
}
|
|
}
|