diff --git a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts index ef8a3e1..b59896b 100644 --- a/pledge-now-pay-later/src/app/api/cron/reminders/route.ts +++ b/pledge-now-pay-later/src/app/api/cron/reminders/route.ts @@ -79,11 +79,22 @@ export async function GET(request: NextRequest) { } try { - // Try to use org's custom due date template (step 4) - const customTemplate = await prisma.messageTemplate.findFirst({ - where: { organizationId: pledge.organizationId, step: 4, channel: "whatsapp", variant: "A" }, + // Try to use org's custom due date template (step 4) with A/B selection + const dueDateTemplates = await prisma.messageTemplate.findMany({ + where: { organizationId: pledge.organizationId, step: 4, channel: "whatsapp", isActive: true }, }) + let customTemplate = dueDateTemplates.find(t => t.variant === "A") || null + // A/B variant selection + if (dueDateTemplates.length > 1) { + const variantB = dueDateTemplates.find(t => t.variant === "B") + if (variantB && customTemplate) { + if (Math.random() * 100 < variantB.splitPercent) { + customTemplate = variantB + } + } + } + const bankDetails = pledge.paymentInstruction?.bankDetails as Record | null const dueFormatted = pledge.dueDate ? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }) @@ -316,7 +327,15 @@ export async function GET(request: NextRequest) { if (result.success) { await prisma.reminder.update({ where: { id: reminder.id }, - data: { status: "sent", sentAt: now }, + data: { + status: "sent", sentAt: now, + payload: { + ...(reminder.payload as object || {}), + templateId: selectedTemplate?.id, + templateVariant: selectedTemplate?.variant, + deliveredVia: "whatsapp", + }, + }, }) // Increment sentCount for A/B tracking diff --git a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts index a9c307f..d16d2b2 100644 --- a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts +++ b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts @@ -91,6 +91,23 @@ export async function PATCH( }) } + // 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 | 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({ diff --git a/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts b/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts index fe45e7d..66c0c52 100644 --- a/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts +++ b/pledge-now-pay-later/src/app/api/whatsapp/webhook/route.ts @@ -91,6 +91,28 @@ export async function POST(request: NextRequest) { where: { id: pledge.id }, data: { status: "initiated", iPaidClickedAt: new Date() }, }) + + // A/B conversion tracking: credit the template variant that drove this action + try { + const lastReminder = await prisma.reminder.findFirst({ + where: { pledgeId: pledge.id, status: "sent" }, + orderBy: { sentAt: "desc" }, + }) + const payload = lastReminder?.payload as Record | null + if (payload?.templateId) { + await prisma.messageTemplate.update({ + where: { id: payload.templateId }, + data: { convertedCount: { increment: 1 } }, + }) + } + } catch { /* best-effort */ } + + // Skip remaining reminders + await prisma.reminder.updateMany({ + where: { pledgeId: pledge.id, status: "pending" }, + data: { status: "skipped" }, + }) + await sendWhatsAppMessage(fromPhone, `✅ Thanks! We've noted that you've paid your *£${amount}* pledge to ${pledge.event.name}.\n\nWe'll confirm once the payment is matched. Ref: \`${pledge.reference}\`` ) diff --git a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx index a561ca0..f745fd4 100644 --- a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx @@ -61,7 +61,6 @@ export default function AutomationsPage() { templates.find(t => t.step === step && t.channel === "whatsapp" && t.variant === variant) || templates.find(t => t.step === step && t.variant === variant) - // Count A/B tests across the 5 message steps const allSteps = STEP_META.map(m => m.step) const testsRunning = allSteps.filter(s => !!tpl(s, "B")).length const stepsWithoutTest = allSteps.filter(s => tpl(s, "A") && !tpl(s, "B")).length @@ -83,16 +82,13 @@ export default function AutomationsPage() { setAiWorking(false) } - /** Regenerate a single AI variant — replaces the existing B with a fresh attempt */ const regenerateVariant = async (step: number) => { setRegenerating(step) try { - // Delete existing B first await fetch("/api/automations", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step, channel: "whatsapp", variant: "B" }), }) - // Generate a new one await fetch("/api/automations/ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "generate_variant", step, channel: "whatsapp" }), @@ -176,17 +172,13 @@ export default function AutomationsPage() { {/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */} {neverOptimised ? (
- {/* Photo — the moment a reminder lands */}
Young man at a London bus stop smiling at his phone — the moment a gentle WhatsApp reminder lands
- {/* Dark panel — CTA */}
@@ -203,8 +195,7 @@ export default function AutomationsPage() { className="mt-6 inline-flex items-center justify-center bg-white px-6 py-3 text-sm font-bold text-[#111827] hover:bg-gray-100 transition-colors self-start disabled:opacity-60"> {aiWorking ? <> AI is writing new versions… - : <> Start optimising - } + : <> Start optimising}

Uses GPT-4.1 nano · Costs less than 1p per message

@@ -242,7 +233,10 @@ export default function AutomationsPage() {
)} - {/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */} + {/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Full messages always visible. No truncation. No eclipse. + A/B tests stack vertically — Yours on top, AI below. + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
@@ -272,11 +266,9 @@ export default function AutomationsPage() { const previewA = a ? resolvePreview(a.body) : "" const previewB = b ? resolvePreview(b.body) : "" - // Due date step: show conditional label const isConditional = meta.conditional const hasDueDateTemplate = !!a - // Timing labels const timeLabel = step === 0 ? "Instantly" : step === 4 ? "On the due date · if set" : step === 1 ? `Day ${delays[1]} · if not paid` : @@ -292,7 +284,6 @@ export default function AutomationsPage() { const hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null - // Don't render the due date step if no template exists yet if (isConditional && !hasDueDateTemplate) return null return ( @@ -306,25 +297,26 @@ export default function AutomationsPage() {
{isEditing ? ( - /* ── EDITING STATE ── */ + /* ── EDITING ── */