Fix AI message generation: enforce variables, due date step, regenerate, style adoption
6 issues identified and fixed:
1. AI-generated messages now ENFORCE required variables per step.
Step 0 (receipt) MUST have {{sort_code}}, {{account_no}}, {{bank_name}}, {{reference}}.
All steps MUST have {{name}}, {{amount}}, {{reference}}.
Validation runs post-generation. Retry on failure. Patch as last resort.
2. Due date message added (step 4).
Schema had dueDate + reminderSentForDueDate but NO message for the day.
Now: 'On the due date · if set' — appears between receipt and first reminder.
Default template: 'Today is the day' with bank details for instant action.
Cron checks Pledge.dueDate = today, fires step 4 template, sets flag.
3. Regenerate button per AI variant.
Hover over any AI card → refresh icon → deletes old B, generates fresh one.
Different psychological approach each time (AI picks from 8 strategies).
4. AI now adopts the user's messaging style.
Prompt analyses variant A for: formality, emoji density, greeting style,
cultural markers, sentence length, sign-off. AI matches all of these
while changing only the psychological APPROACH.
5. Per-step required variables enforced with validateTemplate() + patchMissingVariables().
If AI strips bank details from a receipt, they get patched back in.
6. Cron now uses CUSTOM TEMPLATES from MessageTemplate table (not hardcoded).
A/B variant selection by splitPercent. sentCount incremented for tracking.
Falls back to hardcoded templates only if no custom template exists.
Files changed:
- src/lib/templates.ts — REQUIRED_VARIABLES, validateTemplate(), patchMissingVariables(),
due date template (step 4), STEP_META reordered for display
- src/app/api/automations/ai/route.ts — enforce variables with retry, style adoption prompt,
extractJson() for robust parsing, step-specific rules
- src/app/dashboard/automations/page.tsx — regenerate button, due date message in conversation,
conditional display (amber timestamp for due date step)
- src/app/api/automations/route.ts — backfill due date templates for existing orgs
- src/app/api/cron/reminders/route.ts — due date job, custom template resolution,
A/B variant selection, sentCount tracking
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { getUser } from "@/lib/session"
|
||||
import { validateTemplate, patchMissingVariables, REQUIRED_VARIABLES } from "@/lib/templates"
|
||||
|
||||
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
||||
const GEMINI_KEY = process.env.GEMINI_API_KEY
|
||||
@@ -46,11 +47,26 @@ async function chat(messages: Array<{ role: string; content: string }>, maxToken
|
||||
} catch { return "" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from AI response — handles markdown code blocks, preamble, etc.
|
||||
*/
|
||||
function extractJson(raw: string): string {
|
||||
// Try to find JSON inside ```json ... ``` blocks
|
||||
const codeBlock = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
||||
if (codeBlock) return codeBlock[1].trim()
|
||||
|
||||
// Try to find a JSON object
|
||||
const jsonMatch = raw.match(/\{[\s\S]*\}/)
|
||||
if (jsonMatch) return jsonMatch[0]
|
||||
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/automations/ai
|
||||
*
|
||||
* Actions:
|
||||
* - generate_variant: AI creates a challenger variant B
|
||||
* - generate_variant: AI creates a challenger variant B (with validation + retry)
|
||||
* - rewrite: AI rewrites a template with a specific instruction
|
||||
* - check_winners: Evaluate all A/B tests and auto-promote winners
|
||||
*/
|
||||
@@ -79,10 +95,11 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const stepLabels: Record<number, string> = {
|
||||
0: "pledge receipt (instant confirmation with bank details)",
|
||||
1: "gentle reminder (day 2, donor hasn't paid yet)",
|
||||
2: "impact nudge (day 7, building urgency with purpose)",
|
||||
3: "final reminder (day 14, last message before marking overdue)",
|
||||
0: "pledge receipt (instant confirmation — MUST include full bank transfer details)",
|
||||
4: "due date reminder (sent on the day the donor said they'd pay — MUST include due date and bank details)",
|
||||
1: "gentle reminder (day 2, donor hasn't paid yet — friendly check-in)",
|
||||
2: "impact nudge (day 7, building urgency with purpose — why their money matters)",
|
||||
3: "final reminder (day 14, last message before marking overdue — respect + options)",
|
||||
}
|
||||
|
||||
const channelRules: Record<string, string> = {
|
||||
@@ -91,10 +108,37 @@ export async function POST(request: NextRequest) {
|
||||
sms: "SMS message. MUST be under 160 characters. No formatting. Be extremely concise. Every character counts.",
|
||||
}
|
||||
|
||||
const result = await chat([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach.
|
||||
// Build the required variables string for the prompt
|
||||
const required = REQUIRED_VARIABLES[step] || ["name", "amount", "reference"]
|
||||
const requiredStr = required.map(v => `{{${v}}}`).join(", ")
|
||||
|
||||
// Analyse the user's messaging style from variant A
|
||||
const styleAnalysis = `
|
||||
STYLE MATCHING — THIS IS CRITICAL:
|
||||
Read the user's current message carefully. Match their:
|
||||
- FORMALITY LEVEL (casual vs professional — if they say "Salaam" keep that, if "Dear" keep that)
|
||||
- EMOJI USAGE (match their density — lots of emojis = you use lots, none = you use none)
|
||||
- SENTENCE LENGTH (short punchy = you write short punchy, longer = you write longer)
|
||||
- GREETING STYLE (Salaam, Hi, Hey, Dear — match what they use)
|
||||
- SIGN-OFF STYLE (match their closing — if they use org name, you use org name)
|
||||
- CULTURAL MARKERS (if they reference Islamic giving, mosque, Zakat — keep that energy)
|
||||
- PERSONALITY (warm, direct, formal, friendly — match it exactly)
|
||||
|
||||
You're writing for the SAME organisation. The message should feel like it came from the SAME person, just trying a different psychological angle.`
|
||||
|
||||
// Try up to 2 times — first attempt + 1 retry if validation fails
|
||||
let parsed: { body: string; subject?: string; name?: string; strategy?: string; reasoning?: string } | null = null
|
||||
let lastError = ""
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const retryHint = attempt > 0
|
||||
? `\n\nIMPORTANT: Your previous attempt was rejected because it was MISSING these required variables: ${lastError}. You MUST include them this time. Every {{variable}} listed below MUST appear in your output.`
|
||||
: ""
|
||||
|
||||
const result = await chat([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach.
|
||||
|
||||
Your job: given an existing message (variant A), create a variant B that tests a DIFFERENT strategy. Don't just rephrase — change the APPROACH.
|
||||
|
||||
@@ -108,13 +152,24 @@ Strategies to try (pick ONE that's different from variant A):
|
||||
- LOSS FRAMING: "Your pledge is at risk of being marked unfulfilled"
|
||||
- COMMUNITY: "Join 23 others who've completed their pledge this week"
|
||||
|
||||
${styleAnalysis}
|
||||
|
||||
REQUIRED VARIABLES — YOUR MESSAGE MUST CONTAIN ALL OF THESE:
|
||||
${requiredStr}
|
||||
|
||||
These are template placeholders that get filled in with real data at send time. If you leave any out, the message is BROKEN and the donor won't know how to pay.
|
||||
|
||||
${step === 0 ? `STEP 0 IS THE RECEIPT. The donor just pledged. They NEED the bank details to actually transfer the money. You MUST include a bank details section with {{sort_code}}, {{account_no}}, {{bank_name}}, and {{reference}}. Without this, the donor CANNOT PAY.` : ""}
|
||||
${step === 4 ? `STEP 4 IS THE DUE DATE REMINDER. Sent on the exact day the donor said they'd pay. You MUST include {{due_date}} so they know today is the day. Include bank details ({{sort_code}}, {{account_no}}, {{bank_name}}) so they can pay right now without looking anything up.` : ""}
|
||||
|
||||
All available variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{due_date}}, {{cancel_url}}, {{pledge_url}}
|
||||
|
||||
Rules:
|
||||
- Use the SAME template variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{cancel_url}}, {{pledge_url}}
|
||||
- ${channelRules[channel] || "Keep appropriate for the channel."}
|
||||
- UK English. Warm but professional. Charity context: ${org?.orgType || "charity"}.
|
||||
- UK English. Charity context: ${org?.orgType || "charity"}.
|
||||
- Org name: ${org?.name || "the charity"}
|
||||
|
||||
Return ONLY valid JSON:
|
||||
Return ONLY valid JSON (no markdown, no \`\`\` blocks):
|
||||
{
|
||||
"body": "the new message template with {{variables}}",
|
||||
"subject": "email subject line with {{variables}} (null if not email)",
|
||||
@@ -122,77 +177,112 @@ Return ONLY valid JSON:
|
||||
"strategy": "which strategy you used (e.g. 'social_proof')",
|
||||
"reasoning": "1-2 sentences explaining WHY this approach might outperform variant A"
|
||||
}`
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Step: ${stepLabels[step] || `step ${step}`}
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Step: ${stepLabels[step] || `step ${step}`}
|
||||
Channel: ${channel}
|
||||
Current variant A:
|
||||
---
|
||||
${existing.body}
|
||||
---
|
||||
${retryHint}
|
||||
Generate a challenger variant B using a DIFFERENT psychological approach. Remember: MUST include ${requiredStr}.`
|
||||
}
|
||||
], 900)
|
||||
|
||||
Generate a challenger variant B using a DIFFERENT psychological approach.`
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 })
|
||||
}
|
||||
], 800)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 })
|
||||
try {
|
||||
const cleaned = extractJson(result)
|
||||
parsed = JSON.parse(cleaned)
|
||||
|
||||
if (parsed && parsed.body) {
|
||||
// Validate required variables
|
||||
const validation = validateTemplate(step, parsed.body)
|
||||
|
||||
if (!validation.valid) {
|
||||
// Attempt to patch missing variables
|
||||
parsed.body = patchMissingVariables(step, parsed.body)
|
||||
const recheck = validateTemplate(step, parsed.body)
|
||||
|
||||
if (!recheck.valid) {
|
||||
// Still missing after patching — retry
|
||||
lastError = recheck.missing.map(v => `{{${v}}}`).join(", ")
|
||||
parsed = null
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Validation passed
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
lastError = "Invalid JSON response"
|
||||
parsed = null
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result)
|
||||
|
||||
// Save as variant B
|
||||
await prisma.messageTemplate.upsert({
|
||||
where: {
|
||||
organizationId_step_channel_variant: {
|
||||
organizationId: orgId, step, channel, variant: "B",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
body: parsed.body,
|
||||
subject: parsed.subject || null,
|
||||
name: parsed.name || "AI Challenger",
|
||||
isActive: true,
|
||||
splitPercent: 50,
|
||||
},
|
||||
create: {
|
||||
organizationId: orgId, step, channel, variant: "B",
|
||||
body: parsed.body,
|
||||
subject: parsed.subject || null,
|
||||
name: parsed.name || "AI Challenger",
|
||||
isActive: true,
|
||||
splitPercent: 50,
|
||||
},
|
||||
})
|
||||
|
||||
// Set variant A to 50%
|
||||
await prisma.messageTemplate.updateMany({
|
||||
where: { organizationId: orgId, step, channel, variant: "A" },
|
||||
data: { splitPercent: 50 },
|
||||
})
|
||||
|
||||
if (!parsed || !parsed.body) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
variant: {
|
||||
body: parsed.body,
|
||||
subject: parsed.subject,
|
||||
name: parsed.name,
|
||||
strategy: parsed.strategy,
|
||||
reasoning: parsed.reasoning,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: "AI returned invalid response" }, { status: 500 })
|
||||
error: `AI generated a message but it was missing required information (${lastError}). Try again.`,
|
||||
}, { status: 422 })
|
||||
}
|
||||
|
||||
// Save as variant B
|
||||
await prisma.messageTemplate.upsert({
|
||||
where: {
|
||||
organizationId_step_channel_variant: {
|
||||
organizationId: orgId, step, channel, variant: "B",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
body: parsed.body,
|
||||
subject: parsed.subject || null,
|
||||
name: parsed.name || "AI Challenger",
|
||||
isActive: true,
|
||||
splitPercent: 50,
|
||||
sentCount: 0,
|
||||
convertedCount: 0,
|
||||
},
|
||||
create: {
|
||||
organizationId: orgId, step, channel, variant: "B",
|
||||
body: parsed.body,
|
||||
subject: parsed.subject || null,
|
||||
name: parsed.name || "AI Challenger",
|
||||
isActive: true,
|
||||
splitPercent: 50,
|
||||
},
|
||||
})
|
||||
|
||||
// Set variant A to 50%
|
||||
await prisma.messageTemplate.updateMany({
|
||||
where: { organizationId: orgId, step, channel, variant: "A" },
|
||||
data: { splitPercent: 50 },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
variant: {
|
||||
body: parsed.body,
|
||||
subject: parsed.subject,
|
||||
name: parsed.name,
|
||||
strategy: parsed.strategy,
|
||||
reasoning: parsed.reasoning,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── REWRITE ────────────────────────────────────
|
||||
if (action === "rewrite") {
|
||||
const { instruction, currentBody } = body
|
||||
const { instruction, currentBody, step } = body
|
||||
const channel = body.channel
|
||||
|
||||
const required = REQUIRED_VARIABLES[step] || ["name", "amount", "reference"]
|
||||
const requiredStr = required.map((v: string) => `{{${v}}}`).join(", ")
|
||||
|
||||
const channelHints: Record<string, string> = {
|
||||
whatsapp: "WhatsApp formatting (*bold*, _italic_, `code`). Emojis OK. Conversational.",
|
||||
email: "Email body (plain text). Can include links like {{pledge_url}}.",
|
||||
@@ -202,8 +292,13 @@ Generate a challenger variant B using a DIFFERENT psychological approach.`
|
||||
const result = await chat([
|
||||
{
|
||||
role: "system",
|
||||
content: `You rewrite UK charity fundraising messages. Follow the instruction exactly. Keep ALL {{variable}} placeholders. ${channelHints[channel] || ""}
|
||||
Return ONLY the rewritten message text — nothing else. No JSON, no explanation.`
|
||||
content: `You rewrite UK charity fundraising messages. Follow the instruction exactly.
|
||||
|
||||
CRITICAL: You MUST keep ALL these template variables: ${requiredStr}
|
||||
These are placeholders filled with real data. If you remove any, the message breaks.
|
||||
|
||||
${channelHints[channel] || ""}
|
||||
Return ONLY the rewritten message text — nothing else. No JSON, no explanation, no markdown blocks.`
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -214,7 +309,7 @@ Current message:
|
||||
${currentBody}
|
||||
---
|
||||
|
||||
Rewrite it following the instruction.`
|
||||
Rewrite it following the instruction. MUST include: ${requiredStr}`
|
||||
}
|
||||
], 600)
|
||||
|
||||
@@ -222,7 +317,20 @@ Rewrite it following the instruction.`
|
||||
return NextResponse.json({ error: "AI unavailable" }, { status: 503 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, body: result.trim() })
|
||||
let rewritten = result.trim()
|
||||
.replace(/^```[\s\S]*?\n/, "")
|
||||
.replace(/\n```$/, "")
|
||||
.trim()
|
||||
|
||||
// Validate and patch if needed
|
||||
if (step !== undefined) {
|
||||
const validation = validateTemplate(step, rewritten)
|
||||
if (!validation.valid) {
|
||||
rewritten = patchMissingVariables(step, rewritten)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, body: rewritten })
|
||||
}
|
||||
|
||||
// ─── CHECK WINNERS ──────────────────────────────
|
||||
|
||||
@@ -38,6 +38,27 @@ export async function GET() {
|
||||
body: t.body,
|
||||
})),
|
||||
})
|
||||
} else {
|
||||
// Backfill: seed due date templates (step 4) if they don't exist yet
|
||||
const hasDueDateTemplate = await prisma.messageTemplate.count({
|
||||
where: { organizationId: orgId, step: 4 },
|
||||
})
|
||||
if (hasDueDateTemplate === 0) {
|
||||
const dueDateDefaults = DEFAULT_TEMPLATES.filter(t => t.step === 4)
|
||||
if (dueDateDefaults.length > 0) {
|
||||
await prisma.messageTemplate.createMany({
|
||||
data: dueDateDefaults.map(t => ({
|
||||
organizationId: orgId,
|
||||
step: t.step,
|
||||
channel: t.channel,
|
||||
variant: "A",
|
||||
name: t.name,
|
||||
subject: t.subject || null,
|
||||
body: t.body,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed config if none exists
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { sendPledgeReminder, isWhatsAppReady } from "@/lib/whatsapp"
|
||||
import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp"
|
||||
import { generateReminderContent } from "@/lib/reminders"
|
||||
import { resolveTemplate } from "@/lib/templates"
|
||||
|
||||
/**
|
||||
* Process and send pending reminders.
|
||||
* Process and send pending reminders + due date messages.
|
||||
* Call this via cron every 15 minutes: GET /api/cron/reminders?key=SECRET
|
||||
*
|
||||
* Sends reminders that are:
|
||||
* 1. status = "pending"
|
||||
* 2. scheduledAt <= now
|
||||
* 3. pledge is not paid/cancelled
|
||||
* Two jobs:
|
||||
* 1. Normal reminders (Reminder table, status=pending, scheduledAt <= now)
|
||||
* 2. Due date messages (Pledge.dueDate = today, reminderSentForDueDate = false)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Simple auth via query param or header
|
||||
@@ -26,8 +26,119 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const now = new Date()
|
||||
const whatsappReady = await isWhatsAppReady()
|
||||
const baseUrl = process.env.BASE_URL || "https://pledge.quikcue.com"
|
||||
|
||||
// Find pending reminders that are due
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
let dueDateSent = 0
|
||||
const results: Array<{ id: string; status: string; channel: string; type?: string; error?: string }> = []
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// JOB 1: DUE DATE MESSAGES (step 4)
|
||||
// Find pledges where dueDate is today and we haven't sent the message yet
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const todayEnd = new Date(todayStart.getTime() + 86400000)
|
||||
|
||||
const dueDatePledges = await prisma.pledge.findMany({
|
||||
where: {
|
||||
dueDate: { gte: todayStart, lt: todayEnd },
|
||||
reminderSentForDueDate: false,
|
||||
status: { notIn: ["paid", "cancelled"] },
|
||||
},
|
||||
include: {
|
||||
event: { select: { name: true } },
|
||||
organization: {
|
||||
select: {
|
||||
id: true, name: true, bankSortCode: true,
|
||||
bankAccountNo: true, bankAccountName: true, bankName: true,
|
||||
},
|
||||
},
|
||||
paymentInstruction: true,
|
||||
},
|
||||
take: 50,
|
||||
})
|
||||
|
||||
for (const pledge of dueDatePledges) {
|
||||
const phone = pledge.donorPhone
|
||||
if (!phone || !whatsappReady || !pledge.whatsappOptIn) {
|
||||
// Mark as sent to avoid retrying — no channel available
|
||||
await prisma.pledge.update({
|
||||
where: { id: pledge.id },
|
||||
data: { reminderSentForDueDate: true },
|
||||
})
|
||||
skipped++
|
||||
results.push({ id: pledge.id, status: "skipped", channel: "whatsapp", type: "due_date", error: "No phone/WA" })
|
||||
continue
|
||||
}
|
||||
|
||||
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" },
|
||||
})
|
||||
|
||||
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
||||
const dueFormatted = pledge.dueDate
|
||||
? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })
|
||||
: "today"
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
name: pledge.donorName?.split(" ")[0] || "there",
|
||||
amount: (pledge.amountPence / 100).toFixed(0),
|
||||
event: pledge.event.name,
|
||||
reference: pledge.reference,
|
||||
due_date: dueFormatted,
|
||||
sort_code: bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A",
|
||||
account_no: bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A",
|
||||
bank_name: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A",
|
||||
org_name: pledge.organization.name || "Our charity",
|
||||
days: String(Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)),
|
||||
cancel_url: `${baseUrl}/p/cancel?ref=${pledge.reference}`,
|
||||
pledge_url: `${baseUrl}/p/my-pledges`,
|
||||
}
|
||||
|
||||
let messageText: string
|
||||
if (customTemplate) {
|
||||
messageText = resolveTemplate(customTemplate.body, vars)
|
||||
} else {
|
||||
// Fallback hardcoded due date message
|
||||
messageText = `Salaam ${vars.name} 👋\n\nJust a heads up — your *£${vars.amount}* pledge to *${vars.event}* is due today (${vars.due_date}).\n\nYour ref: \`${vars.reference}\`\n\n━━━━━━━━━━━━━━━━━━\nSort Code: \`${vars.sort_code}\`\nAccount: \`${vars.account_no}\`\nName: ${vars.bank_name}\nReference: \`${vars.reference}\`\n━━━━━━━━━━━━━━━━━━\n\nAlready sent it? Reply *PAID* 🙏\nNeed help? Reply *HELP*`
|
||||
}
|
||||
|
||||
const result = await sendWhatsAppMessage(phone, messageText)
|
||||
|
||||
if (result.success) {
|
||||
await prisma.pledge.update({
|
||||
where: { id: pledge.id },
|
||||
data: { reminderSentForDueDate: true },
|
||||
})
|
||||
|
||||
// Increment sentCount on the template for A/B tracking
|
||||
if (customTemplate) {
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id: customTemplate.id },
|
||||
data: { sentCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
dueDateSent++
|
||||
results.push({ id: pledge.id, status: "sent", channel: "whatsapp", type: "due_date" })
|
||||
} else {
|
||||
failed++
|
||||
results.push({ id: pledge.id, status: "failed", channel: "whatsapp", type: "due_date", error: result.error })
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
results.push({ id: pledge.id, status: "failed", channel: "whatsapp", type: "due_date", error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// JOB 2: NORMAL REMINDERS (steps 0-3)
|
||||
// Uses custom templates from MessageTemplate when available
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const dueReminders = await prisma.reminder.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
@@ -40,50 +151,95 @@ export async function GET(request: NextRequest) {
|
||||
pledge: {
|
||||
include: {
|
||||
event: { select: { name: true } },
|
||||
organization: { select: { name: true, bankSortCode: true, bankAccountNo: true, bankAccountName: true } },
|
||||
organization: {
|
||||
select: {
|
||||
id: true, name: true, bankSortCode: true,
|
||||
bankAccountNo: true, bankAccountName: true, bankName: true,
|
||||
},
|
||||
},
|
||||
paymentInstruction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 50, // Process in batches
|
||||
take: 50,
|
||||
orderBy: { scheduledAt: "asc" },
|
||||
})
|
||||
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
const results: Array<{ id: string; status: string; channel: string; error?: string }> = []
|
||||
|
||||
for (const reminder of dueReminders) {
|
||||
const pledge = reminder.pledge
|
||||
const phone = pledge.donorPhone
|
||||
const email = pledge.donorEmail
|
||||
const channel = reminder.channel
|
||||
const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)
|
||||
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
||||
|
||||
// Build template variables
|
||||
const vars: Record<string, string> = {
|
||||
name: pledge.donorName?.split(" ")[0] || "there",
|
||||
amount: (pledge.amountPence / 100).toFixed(0),
|
||||
event: pledge.event.name,
|
||||
reference: pledge.reference,
|
||||
sort_code: bankDetails?.sortCode || pledge.organization.bankSortCode || "N/A",
|
||||
account_no: bankDetails?.accountNo || pledge.organization.bankAccountNo || "N/A",
|
||||
bank_name: bankDetails?.accountName || pledge.organization.bankAccountName || pledge.organization.name || "N/A",
|
||||
org_name: pledge.organization.name || "Our charity",
|
||||
days: String(daysSince),
|
||||
due_date: pledge.dueDate
|
||||
? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })
|
||||
: "",
|
||||
cancel_url: `${baseUrl}/p/cancel?ref=${pledge.reference}`,
|
||||
pledge_url: `${baseUrl}/p/my-pledges`,
|
||||
}
|
||||
|
||||
try {
|
||||
// WhatsApp channel — only if donor consented
|
||||
// ── WhatsApp ──
|
||||
if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) {
|
||||
const result = await sendPledgeReminder(phone, {
|
||||
donorName: pledge.donorName || undefined,
|
||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
||||
eventName: pledge.event.name,
|
||||
reference: pledge.reference,
|
||||
daysSincePledge: daysSince,
|
||||
step: reminder.step,
|
||||
// Try to use org's custom template + A/B variant selection
|
||||
const orgTemplates = await prisma.messageTemplate.findMany({
|
||||
where: {
|
||||
organizationId: pledge.organizationId,
|
||||
step: reminder.step,
|
||||
channel: "whatsapp",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now },
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp" })
|
||||
let selectedTemplate = orgTemplates.find(t => t.variant === "A")
|
||||
let messageText: string
|
||||
|
||||
// A/B variant selection based on splitPercent
|
||||
if (orgTemplates.length > 1) {
|
||||
const variantB = orgTemplates.find(t => t.variant === "B")
|
||||
if (variantB && selectedTemplate) {
|
||||
// Random selection weighted by splitPercent
|
||||
const roll = Math.random() * 100
|
||||
if (roll < variantB.splitPercent) {
|
||||
selectedTemplate = variantB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
messageText = resolveTemplate(selectedTemplate.body, vars)
|
||||
} else {
|
||||
// Try email fallback
|
||||
if (email) {
|
||||
// For now, mark as sent (email integration is external via webhook API)
|
||||
// Fallback to hardcoded templates
|
||||
const { sendPledgeReminder } = await import("@/lib/whatsapp")
|
||||
const result = await sendPledgeReminder(phone, {
|
||||
donorName: pledge.donorName || undefined,
|
||||
amountPounds: vars.amount,
|
||||
eventName: pledge.event.name,
|
||||
reference: pledge.reference,
|
||||
daysSincePledge: daysSince,
|
||||
step: reminder.step,
|
||||
})
|
||||
if (result.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now },
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp" })
|
||||
} else if (email) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now, payload: { ...(reminder.payload as object || {}), fallback: "email", waError: result.error } },
|
||||
@@ -94,51 +250,111 @@ export async function GET(request: NextRequest) {
|
||||
failed++
|
||||
results.push({ id: reminder.id, status: "failed", channel: "whatsapp", error: result.error })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await sendWhatsAppMessage(phone, messageText)
|
||||
|
||||
if (result.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now },
|
||||
})
|
||||
|
||||
// Increment sentCount for A/B tracking
|
||||
if (selectedTemplate) {
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id: selectedTemplate.id },
|
||||
data: { sentCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp" })
|
||||
} else if (email) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now, payload: { ...(reminder.payload as object || {}), fallback: "email", waError: result.error } },
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "email-fallback" })
|
||||
} else {
|
||||
failed++
|
||||
results.push({ id: reminder.id, status: "failed", channel: "whatsapp", error: result.error })
|
||||
}
|
||||
}
|
||||
// Email channel — only if donor consented
|
||||
// ── Email ──
|
||||
else if (channel === "email" && email && pledge.emailOptIn) {
|
||||
// Generate content and store for external pickup
|
||||
const payload = reminder.payload as Record<string, string> || {}
|
||||
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
||||
const cancelUrl = `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/cancel?ref=${pledge.reference}`
|
||||
const cancelUrl = `${baseUrl}/p/cancel?ref=${pledge.reference}`
|
||||
|
||||
const content = generateReminderContent(payload.templateKey || "gentle_nudge", {
|
||||
donorName: pledge.donorName || undefined,
|
||||
amount: (pledge.amountPence / 100).toFixed(0),
|
||||
reference: pledge.reference,
|
||||
eventName: pledge.event.name,
|
||||
bankName: bankDetails?.bankName,
|
||||
sortCode: bankDetails?.sortCode,
|
||||
accountNo: bankDetails?.accountNo,
|
||||
accountName: bankDetails?.accountName,
|
||||
pledgeUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/my-pledges`,
|
||||
cancelUrl,
|
||||
// Try custom email template
|
||||
const emailTemplate = await prisma.messageTemplate.findFirst({
|
||||
where: {
|
||||
organizationId: pledge.organizationId,
|
||||
step: reminder.step,
|
||||
channel: "email",
|
||||
isActive: true,
|
||||
variant: "A",
|
||||
},
|
||||
})
|
||||
|
||||
// Try WhatsApp as fallback if phone exists and WhatsApp is ready
|
||||
if (phone && whatsappReady && pledge.whatsappOptIn) {
|
||||
const waResult = await sendPledgeReminder(phone, {
|
||||
let subject: string
|
||||
let body: string
|
||||
|
||||
if (emailTemplate) {
|
||||
subject = emailTemplate.subject ? resolveTemplate(emailTemplate.subject, vars) : `Pledge reminder — £${vars.amount}`
|
||||
body = resolveTemplate(emailTemplate.body, vars)
|
||||
} else {
|
||||
const content = generateReminderContent(payload.templateKey || "gentle_nudge", {
|
||||
donorName: pledge.donorName || undefined,
|
||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
||||
eventName: pledge.event.name,
|
||||
amount: vars.amount,
|
||||
reference: pledge.reference,
|
||||
daysSincePledge: daysSince,
|
||||
step: reminder.step,
|
||||
eventName: pledge.event.name,
|
||||
bankName: bankDetails?.bankName,
|
||||
sortCode: bankDetails?.sortCode,
|
||||
accountNo: bankDetails?.accountNo,
|
||||
accountName: bankDetails?.accountName,
|
||||
pledgeUrl: `${baseUrl}/p/my-pledges`,
|
||||
cancelUrl,
|
||||
})
|
||||
if (waResult.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now, payload: { ...payload, deliveredVia: "whatsapp-fallback" } },
|
||||
})
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp-fallback" })
|
||||
continue
|
||||
subject = content.subject
|
||||
body = content.body
|
||||
}
|
||||
|
||||
// Try WhatsApp as fallback if phone exists
|
||||
if (phone && whatsappReady && pledge.whatsappOptIn) {
|
||||
const waTemplate = await prisma.messageTemplate.findFirst({
|
||||
where: {
|
||||
organizationId: pledge.organizationId,
|
||||
step: reminder.step,
|
||||
channel: "whatsapp",
|
||||
isActive: true,
|
||||
variant: "A",
|
||||
},
|
||||
})
|
||||
if (waTemplate) {
|
||||
const waText = resolveTemplate(waTemplate.body, vars)
|
||||
const waResult = await sendWhatsAppMessage(phone, waText)
|
||||
if (waResult.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now, payload: { ...payload, deliveredVia: "whatsapp-fallback" } },
|
||||
})
|
||||
if (waTemplate) {
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id: waTemplate.id },
|
||||
data: { sentCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp-fallback" })
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as "pending_email" — honestly indicates content is ready but not yet delivered
|
||||
// Staff can see these in dashboard and send manually, or external tools can pick up via webhook
|
||||
// Queue email
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: {
|
||||
@@ -146,15 +362,22 @@ export async function GET(request: NextRequest) {
|
||||
sentAt: now,
|
||||
payload: {
|
||||
...payload,
|
||||
generatedSubject: content.subject,
|
||||
generatedBody: content.body,
|
||||
generatedSubject: subject,
|
||||
generatedBody: body,
|
||||
recipientEmail: email,
|
||||
cancelUrl,
|
||||
deliveredVia: "email-queued",
|
||||
note: "Email content generated. Awaiting external delivery via /api/webhooks or manual send.",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (emailTemplate) {
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id: emailTemplate.id },
|
||||
data: { sentCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
sent++
|
||||
results.push({ id: reminder.id, status: "sent", channel: "email-queued" })
|
||||
}
|
||||
@@ -175,8 +398,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
processed: dueReminders.length,
|
||||
processed: dueReminders.length + dueDatePledges.length,
|
||||
sent,
|
||||
dueDateSent,
|
||||
skipped,
|
||||
failed,
|
||||
whatsappReady,
|
||||
|
||||
@@ -4,26 +4,11 @@ import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
Loader2, Check, Send, Sparkles, Trophy, CheckCheck,
|
||||
ChevronDown, Clock, MessageCircle
|
||||
ChevronDown, Clock, MessageCircle, RefreshCw, Calendar
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { resolvePreview, STEP_META } from "@/lib/templates"
|
||||
|
||||
/**
|
||||
* /dashboard/automations
|
||||
*
|
||||
* AI DOES THE WORK. PHOTOGRAPHY SETS THE CONTEXT.
|
||||
*
|
||||
* The AI hero uses the same image-panel + dark-panel split from
|
||||
* the landing page. The image is `digital-03-notification-smile` —
|
||||
* a young man at a bus stop smiling at his phone. It IS the product
|
||||
* working. The moment a WhatsApp reminder lands and someone thinks
|
||||
* "oh right, I need to do that."
|
||||
*
|
||||
* Once AI is running, the hero compacts down — the image served its
|
||||
* purpose (motivation to start). Now the data takes over.
|
||||
*/
|
||||
|
||||
interface Template {
|
||||
id: string; step: number; channel: string; variant: string
|
||||
name: string; subject: string | null; body: string
|
||||
@@ -53,6 +38,7 @@ export default function AutomationsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState<number | null>(null)
|
||||
const [aiWorking, setAiWorking] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState<number | null>(null)
|
||||
const [showTiming, setShowTiming] = useState(false)
|
||||
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -75,14 +61,17 @@ 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)
|
||||
|
||||
const testsRunning = STEP_META.filter((_, i) => !!tpl(i, "B")).length
|
||||
const stepsWithoutTest = STEP_META.filter((_, i) => !tpl(i, "B")).length
|
||||
// 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
|
||||
const neverOptimised = testsRunning === 0 && templates.every(t => t.variant === "A")
|
||||
|
||||
const optimiseAll = async () => {
|
||||
setAiWorking(true)
|
||||
for (let step = 0; step < 4; step++) {
|
||||
for (const step of allSteps) {
|
||||
if (tpl(step, "B")) continue
|
||||
if (!tpl(step, "A")) continue
|
||||
try {
|
||||
await fetch("/api/automations/ai", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
@@ -94,6 +83,25 @@ 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" }),
|
||||
})
|
||||
} catch { /* */ }
|
||||
await load()
|
||||
setRegenerating(null)
|
||||
}
|
||||
|
||||
const pickWinnersAndContinue = async () => {
|
||||
setAiWorking(true)
|
||||
try {
|
||||
@@ -154,7 +162,7 @@ export default function AutomationsPage() {
|
||||
What your donors receive
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
4 messages over {delays[3]} days. Click any to edit.
|
||||
5 messages — a receipt, a due date nudge, and 3 reminders. Click any to edit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -165,12 +173,7 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Same image-panel + dark-panel split from the landing page.
|
||||
The photo is the PRODUCT WORKING — a real person receiving
|
||||
a WhatsApp reminder and acting on it.
|
||||
Once AI is running, the hero compacts. The image did its job.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
{/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
{neverOptimised ? (
|
||||
<div className="grid md:grid-cols-5 gap-0">
|
||||
{/* Photo — the moment a reminder lands */}
|
||||
@@ -207,7 +210,6 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : testsRunning > 0 ? (
|
||||
/* ── Compact hero: tests running ── */
|
||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<Sparkles className="h-5 w-5 text-[#60A5FA]" />
|
||||
@@ -225,7 +227,6 @@ export default function AutomationsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Compact hero: optimised ── */
|
||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||
<Trophy className="h-5 w-5 text-[#16A34A] shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -241,10 +242,7 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
The WhatsApp mockup uses rounded corners because it IS a
|
||||
phone. Everything else follows brand rules (sharp edges).
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
||||
|
||||
@@ -264,15 +262,29 @@ export default function AutomationsPage() {
|
||||
<div className="bg-[#ECE5DD] px-4 py-4 space-y-4" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}}>
|
||||
{STEP_META.map((meta, step) => {
|
||||
{STEP_META.map((meta) => {
|
||||
const step = meta.step
|
||||
const a = tpl(step, "A")
|
||||
const b = tpl(step, "B")
|
||||
const isEditing = editing === step
|
||||
const justSaved = saved === step
|
||||
const delay = delays[step]
|
||||
const isRegenning = regenerating === step
|
||||
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` :
|
||||
step === 2 ? `Day ${delays[2]} · if not paid` :
|
||||
`Day ${delays[3]} · if not paid`
|
||||
|
||||
const clockTime = step === 0 ? "09:41" : step === 4 ? "08:00" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"
|
||||
|
||||
const rateA = a && a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0
|
||||
const rateB = b && b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0
|
||||
const totalSent = (a?.sentCount || 0) + (b?.sentCount || 0)
|
||||
@@ -280,16 +292,21 @@ 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 (
|
||||
<div key={step}>
|
||||
{/* Timestamp */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<span className="bg-white/80 text-[10px] text-[#667781] px-3 py-1 font-medium shadow-sm" style={{ borderRadius: "6px" }}>
|
||||
{step === 0 ? "Instantly" : `Day ${delay} · if not paid`}
|
||||
<span className={`text-[10px] px-3 py-1 font-medium shadow-sm flex items-center gap-1.5 ${isConditional ? "bg-[#FEF3C7] text-[#92400E]" : "bg-white/80 text-[#667781]"}`} style={{ borderRadius: "6px" }}>
|
||||
{isConditional && <Calendar className="h-2.5 w-2.5" />}
|
||||
{timeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
/* ── EDITING STATE ── */
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-[#DCF8C6] max-w-[90%] w-full shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
||||
<textarea ref={editorRef} value={editBody} onChange={e => setEditBody(e.target.value)}
|
||||
@@ -305,6 +322,7 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : b ? (
|
||||
/* ── A/B TEST STATE ── */
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
||||
<div className="bg-[#075E54] text-white px-3 py-1.5 flex items-center gap-1.5" style={{ borderRadius: "8px 8px 0 0" }}>
|
||||
@@ -318,6 +336,7 @@ export default function AutomationsPage() {
|
||||
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-px bg-[#075E54]/20">
|
||||
{/* Variant A — yours */}
|
||||
<button onClick={() => startEdit(step)} className="bg-[#DCF8C6] p-2.5 text-left hover:brightness-[0.97] transition-all">
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="text-[8px] font-bold text-[#075E54] bg-[#075E54]/10 px-1.5 py-0.5">Yours</span>
|
||||
@@ -326,13 +345,27 @@ export default function AutomationsPage() {
|
||||
<div className="text-[10px] leading-[1.4] text-[#303030] line-clamp-4"><WhatsAppFormatted text={previewA} /></div>
|
||||
{a && a.sentCount > 0 && <p className="text-[8px] text-[#667781] mt-1">{a.convertedCount}/{a.sentCount} paid</p>}
|
||||
</button>
|
||||
<div className="bg-[#DCF8C6] p-2.5">
|
||||
{/* Variant B — AI */}
|
||||
<div className="bg-[#DCF8C6] p-2.5 relative group">
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="text-[8px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5 flex items-center gap-0.5"><Sparkles className="h-2 w-2" /> AI</span>
|
||||
{b.sentCount > 0 && <span className={`text-[9px] font-bold ml-auto ${winner === "B" ? "text-[#075E54]" : "text-[#667781]"}`}>{rateB}%{winner === "B" && " 🏆"}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] leading-[1.4] text-[#303030] line-clamp-4"><WhatsAppFormatted text={previewB} /></div>
|
||||
{b.sentCount > 0 && <p className="text-[8px] text-[#667781] mt-1">{b.convertedCount}/{b.sentCount} paid</p>}
|
||||
{/* Regenerate button */}
|
||||
<button
|
||||
onClick={() => regenerateVariant(step)}
|
||||
disabled={isRegenning}
|
||||
className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 bg-white/90 p-1 shadow-sm transition-opacity hover:bg-white disabled:opacity-50"
|
||||
style={{ borderRadius: "4px" }}
|
||||
title="Try a different AI version"
|
||||
>
|
||||
{isRegenning
|
||||
? <Loader2 className="h-3 w-3 text-[#1E40AF] animate-spin" />
|
||||
: <RefreshCw className="h-3 w-3 text-[#1E40AF]" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/60 px-3 py-1.5" style={{ borderRadius: "0 0 8px 8px" }}>
|
||||
@@ -348,6 +381,7 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── NORMAL MESSAGE ── */
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => startEdit(step)}
|
||||
className="bg-[#DCF8C6] max-w-[85%] px-3 py-2 text-left text-[12px] leading-[1.45] text-[#303030] relative shadow-sm cursor-pointer hover:brightness-[0.97] transition-all"
|
||||
@@ -359,7 +393,7 @@ export default function AutomationsPage() {
|
||||
)}
|
||||
<WhatsAppFormatted text={previewA} />
|
||||
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
||||
<span className="text-[9px] text-[#667781]">{step === 0 ? "09:41" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"}</span>
|
||||
<span className="text-[9px] text-[#667781]">{clockTime}</span>
|
||||
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -395,7 +429,7 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Timing (expandable, in brand language) ── */}
|
||||
{/* ── Timing ── */}
|
||||
<div className="max-w-lg mx-auto">
|
||||
<button onClick={() => setShowTiming(!showTiming)}
|
||||
className="flex items-center gap-2 text-[11px] text-gray-400 hover:text-gray-600 transition-colors py-1 font-semibold tracking-wide uppercase">
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* {{account_no}} — account number
|
||||
* {{org_name}} — charity name
|
||||
* {{days}} — days since pledge
|
||||
* {{due_date}} — formatted due date e.g. "Friday 14 March"
|
||||
* {{cancel_url}} — link to cancel pledge
|
||||
* {{pledge_url}} — link to view pledges
|
||||
*/
|
||||
@@ -26,6 +27,93 @@ export interface TemplateDefaults {
|
||||
body: string
|
||||
}
|
||||
|
||||
// ─── Required variables per step ─────────────────────────────
|
||||
// AI MUST include these. If missing, the message is broken.
|
||||
|
||||
export const REQUIRED_VARIABLES: Record<number, string[]> = {
|
||||
0: ["name", "amount", "event", "reference", "sort_code", "account_no", "bank_name"],
|
||||
4: ["name", "amount", "event", "reference", "due_date"],
|
||||
1: ["name", "amount", "reference"],
|
||||
2: ["name", "amount", "reference"],
|
||||
3: ["name", "amount", "reference"],
|
||||
}
|
||||
|
||||
// Variables that SHOULD be present (warn if missing, but don't reject)
|
||||
export const RECOMMENDED_VARIABLES: Record<number, string[]> = {
|
||||
0: ["org_name"],
|
||||
4: ["sort_code", "account_no", "bank_name"],
|
||||
1: ["event"],
|
||||
2: ["event", "days"],
|
||||
3: ["event"],
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a template body contains all required variables for its step.
|
||||
* Returns { valid, missing, warnings }
|
||||
*/
|
||||
export function validateTemplate(step: number, body: string): {
|
||||
valid: boolean
|
||||
missing: string[]
|
||||
warnings: string[]
|
||||
} {
|
||||
const required = REQUIRED_VARIABLES[step] || ["name", "amount", "reference"]
|
||||
const recommended = RECOMMENDED_VARIABLES[step] || []
|
||||
|
||||
const missing = required.filter(v => !body.includes(`{{${v}}}`))
|
||||
const warnings = recommended.filter(v => !body.includes(`{{${v}}}`))
|
||||
|
||||
return { valid: missing.length === 0, missing, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch missing required variables back into a message body.
|
||||
* Appends them at the end in a natural way. Last resort — better than
|
||||
* sending a message that's missing the payment reference.
|
||||
*/
|
||||
export function patchMissingVariables(step: number, body: string): string {
|
||||
const { missing } = validateTemplate(step, body)
|
||||
if (missing.length === 0) return body
|
||||
|
||||
let patched = body.trimEnd()
|
||||
|
||||
// Step 0 (receipt) — must have bank details block
|
||||
if (step === 0) {
|
||||
const needsBank = missing.some(v => ["sort_code", "account_no", "bank_name"].includes(v))
|
||||
if (needsBank) {
|
||||
patched += `\n\n━━━━━━━━━━━━━━━━━━\n*Transfer to:*\nSort Code: \`{{sort_code}}\`\nAccount: \`{{account_no}}\`\nName: {{bank_name}}\nReference: \`{{reference}}\`\n━━━━━━━━━━━━━━━━━━`
|
||||
// Remove patched vars from missing
|
||||
const stillMissing = missing.filter(v => !["sort_code", "account_no", "bank_name", "reference"].includes(v))
|
||||
if (stillMissing.includes("name")) patched = patched.replace(/^/, "Hi {{name}},\n\n")
|
||||
if (stillMissing.includes("amount")) patched += `\n\n💷 Amount: *£{{amount}}*`
|
||||
if (stillMissing.includes("event")) patched += ` to *{{event}}*`
|
||||
return patched
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4 (due date) — must have due date
|
||||
if (step === 4 && missing.includes("due_date")) {
|
||||
patched += `\n\n📅 Due: *{{due_date}}*`
|
||||
}
|
||||
|
||||
// Generic: append any still-missing required vars
|
||||
const { missing: stillMissing } = validateTemplate(step, patched)
|
||||
if (stillMissing.length > 0) {
|
||||
const varLabels: Record<string, string> = {
|
||||
name: "{{name}}",
|
||||
amount: "£{{amount}}",
|
||||
reference: "Ref: `{{reference}}`",
|
||||
event: "{{event}}",
|
||||
due_date: "Due: {{due_date}}",
|
||||
sort_code: "Sort: `{{sort_code}}`",
|
||||
account_no: "Acc: `{{account_no}}`",
|
||||
bank_name: "{{bank_name}}",
|
||||
}
|
||||
patched += "\n\n" + stillMissing.map(v => varLabels[v] || `{{${v}}}`).join("\n")
|
||||
}
|
||||
|
||||
return patched
|
||||
}
|
||||
|
||||
// ─── WhatsApp templates ──────────────────────────────────────
|
||||
|
||||
const WA_RECEIPT = `🤲 *Pledge Confirmed!*
|
||||
@@ -47,6 +135,22 @@ Reference: \`{{reference}}\`
|
||||
|
||||
Reply *HELP* anytime 💚`
|
||||
|
||||
const WA_DUE_DATE = `Salaam {{name}} 👋
|
||||
|
||||
Just a heads up — your *£{{amount}}* pledge to *{{event}}* is due today ({{due_date}}).
|
||||
|
||||
Your ref: \`{{reference}}\`
|
||||
|
||||
━━━━━━━━━━━━━━━━━━
|
||||
Sort Code: \`{{sort_code}}\`
|
||||
Account: \`{{account_no}}\`
|
||||
Name: {{bank_name}}
|
||||
Reference: \`{{reference}}\`
|
||||
━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Already sent it? Reply *PAID* 🙏
|
||||
Need help? Reply *HELP*`
|
||||
|
||||
const WA_GENTLE = `Hi {{name}} 👋
|
||||
|
||||
Just a quick reminder about your *£{{amount}}* pledge to {{event}}.
|
||||
@@ -99,6 +203,23 @@ Thank you for your generosity!
|
||||
|
||||
{{org_name}}`
|
||||
|
||||
const EMAIL_DUE_DATE = `Hi {{name}},
|
||||
|
||||
Your £{{amount}} pledge to {{event}} is due today ({{due_date}}).
|
||||
|
||||
To complete your donation:
|
||||
|
||||
Bank: {{bank_name}}
|
||||
Sort Code: {{sort_code}}
|
||||
Account: {{account_no}}
|
||||
Reference: {{reference}}
|
||||
|
||||
View your pledge: {{pledge_url}}
|
||||
|
||||
Already paid? You can ignore this message.
|
||||
|
||||
{{org_name}}`
|
||||
|
||||
const EMAIL_GENTLE = `Hi {{name}},
|
||||
|
||||
Just a friendly reminder about your £{{amount}} pledge at {{event}}.
|
||||
@@ -140,6 +261,8 @@ Thank you for considering us.
|
||||
|
||||
const SMS_RECEIPT = `Thank you, {{name}}! £{{amount}} pledged to {{event}}. Ref: {{reference}}. Transfer to SC {{sort_code}} Acc {{account_no}} Name {{bank_name}}. Use exact ref!`
|
||||
|
||||
const SMS_DUE_DATE = `Hi {{name}}, your £{{amount}} pledge to {{event}} is due today. Ref: {{reference}}. SC {{sort_code}} Acc {{account_no}}. Reply PAID once sent.`
|
||||
|
||||
const SMS_GENTLE = `Hi {{name}}, reminder: your £{{amount}} pledge to {{event}} ref {{reference}} is pending. Already paid? Ignore this. Need help? Reply HELP.`
|
||||
|
||||
const SMS_IMPACT = `{{name}}, your £{{amount}} to {{event}} (ref: {{reference}}) is {{days}} days old. Every pound counts. Reply PAID or CANCEL.`
|
||||
@@ -149,10 +272,14 @@ const SMS_FINAL = `Final reminder: £{{amount}} pledge to {{event}}, ref {{refer
|
||||
// ─── All defaults ────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_TEMPLATES: TemplateDefaults[] = [
|
||||
// Step 0: Receipt
|
||||
// Step 0: Receipt (instant)
|
||||
{ step: 0, channel: "whatsapp", name: "Pledge receipt", body: WA_RECEIPT },
|
||||
{ step: 0, channel: "email", name: "Pledge receipt", subject: "Your £{{amount}} pledge — payment details", body: EMAIL_RECEIPT },
|
||||
{ step: 0, channel: "sms", name: "Pledge receipt", body: SMS_RECEIPT },
|
||||
// Step 4: Due date reminder (on the day)
|
||||
{ step: 4, channel: "whatsapp", name: "Due date reminder", body: WA_DUE_DATE },
|
||||
{ step: 4, channel: "email", name: "Due date reminder", subject: "Your £{{amount}} pledge is due today", body: EMAIL_DUE_DATE },
|
||||
{ step: 4, channel: "sms", name: "Due date reminder", body: SMS_DUE_DATE },
|
||||
// Step 1: Day 2 gentle reminder
|
||||
{ step: 1, channel: "whatsapp", name: "Gentle reminder", body: WA_GENTLE },
|
||||
{ step: 1, channel: "email", name: "Gentle reminder", subject: "Quick reminder: your £{{amount}} pledge", body: EMAIL_GENTLE },
|
||||
@@ -179,6 +306,7 @@ export const TEMPLATE_VARIABLES = [
|
||||
{ key: "account_no", label: "Account number", example: "12345678" },
|
||||
{ key: "org_name", label: "Charity name", example: "Al Furqan Mosque" },
|
||||
{ key: "days", label: "Days since pledge", example: "7" },
|
||||
{ key: "due_date", label: "Due date", example: "Friday 14 March" },
|
||||
{ key: "cancel_url", label: "Cancel link", example: "pledge.quikcue.com/p/cancel?ref=..." },
|
||||
{ key: "pledge_url", label: "Pledge link", example: "pledge.quikcue.com/p/my-pledges" },
|
||||
]
|
||||
@@ -202,9 +330,12 @@ export function resolvePreview(body: string): string {
|
||||
}
|
||||
|
||||
// ─── Step metadata ───────────────────────────────────────────
|
||||
// DISPLAY ORDER — this is the order shown in the conversation.
|
||||
// Step 4 (due date) sits between receipt and first reminder.
|
||||
|
||||
export const STEP_META = [
|
||||
{ step: 0, trigger: "Instantly", label: "Receipt", desc: "Pledge confirmation with bank details", icon: "✉️" },
|
||||
{ step: 4, trigger: "On the due date", label: "Due date", desc: "The day they said they'd pay", icon: "📅", conditional: true },
|
||||
{ step: 1, trigger: "Day 2", label: "Gentle reminder", desc: "Friendly nudge if not yet paid", icon: "👋" },
|
||||
{ step: 2, trigger: "Day 7", label: "Impact nudge", desc: "Why their donation matters", icon: "💚" },
|
||||
{ step: 3, trigger: "Day 14", label: "Final reminder", desc: "Last message — reply PAID or CANCEL", icon: "🔔" },
|
||||
@@ -226,6 +357,7 @@ export const STRATEGY_PRESETS: StrategyPreset[] = [
|
||||
desc: "Try WhatsApp first, then SMS, then Email. Most cost-effective.",
|
||||
matrix: {
|
||||
"0": ["whatsapp", "sms", "email"],
|
||||
"4": ["whatsapp", "sms", "email"],
|
||||
"1": ["whatsapp", "sms", "email"],
|
||||
"2": ["whatsapp", "sms", "email"],
|
||||
"3": ["whatsapp", "sms", "email"],
|
||||
@@ -237,6 +369,7 @@ export const STRATEGY_PRESETS: StrategyPreset[] = [
|
||||
desc: "Send via ALL available channels. Maximum reach for important messages.",
|
||||
matrix: {
|
||||
"0": ["whatsapp+email"],
|
||||
"4": ["whatsapp+email"],
|
||||
"1": ["whatsapp"],
|
||||
"2": ["whatsapp+email"],
|
||||
"3": ["whatsapp+email+sms"],
|
||||
@@ -248,6 +381,7 @@ export const STRATEGY_PRESETS: StrategyPreset[] = [
|
||||
desc: "Start with WhatsApp only, add channels as urgency increases.",
|
||||
matrix: {
|
||||
"0": ["whatsapp", "email"],
|
||||
"4": ["whatsapp"],
|
||||
"1": ["whatsapp"],
|
||||
"2": ["whatsapp", "email"],
|
||||
"3": ["whatsapp+email+sms"],
|
||||
|
||||
Reference in New Issue
Block a user