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 { NextRequest, NextResponse } from "next/server"
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { getUser } from "@/lib/session"
|
import { getUser } from "@/lib/session"
|
||||||
|
import { validateTemplate, patchMissingVariables, REQUIRED_VARIABLES } from "@/lib/templates"
|
||||||
|
|
||||||
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
||||||
const GEMINI_KEY = process.env.GEMINI_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 "" }
|
} 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
|
* POST /api/automations/ai
|
||||||
*
|
*
|
||||||
* Actions:
|
* 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
|
* - rewrite: AI rewrites a template with a specific instruction
|
||||||
* - check_winners: Evaluate all A/B tests and auto-promote winners
|
* - 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> = {
|
const stepLabels: Record<number, string> = {
|
||||||
0: "pledge receipt (instant confirmation with bank details)",
|
0: "pledge receipt (instant confirmation — MUST include full bank transfer details)",
|
||||||
1: "gentle reminder (day 2, donor hasn't paid yet)",
|
4: "due date reminder (sent on the day the donor said they'd pay — MUST include due date and bank details)",
|
||||||
2: "impact nudge (day 7, building urgency with purpose)",
|
1: "gentle reminder (day 2, donor hasn't paid yet — friendly check-in)",
|
||||||
3: "final reminder (day 14, last message before marking overdue)",
|
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> = {
|
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.",
|
sms: "SMS message. MUST be under 160 characters. No formatting. Be extremely concise. Every character counts.",
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chat([
|
// Build the required variables string for the prompt
|
||||||
{
|
const required = REQUIRED_VARIABLES[step] || ["name", "amount", "reference"]
|
||||||
role: "system",
|
const requiredStr = required.map(v => `{{${v}}}`).join(", ")
|
||||||
content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach.
|
|
||||||
|
// 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.
|
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"
|
- LOSS FRAMING: "Your pledge is at risk of being marked unfulfilled"
|
||||||
- COMMUNITY: "Join 23 others who've completed their pledge this week"
|
- 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:
|
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."}
|
- ${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"}
|
- 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}}",
|
"body": "the new message template with {{variables}}",
|
||||||
"subject": "email subject line with {{variables}} (null if not email)",
|
"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')",
|
"strategy": "which strategy you used (e.g. 'social_proof')",
|
||||||
"reasoning": "1-2 sentences explaining WHY this approach might outperform variant A"
|
"reasoning": "1-2 sentences explaining WHY this approach might outperform variant A"
|
||||||
}`
|
}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: `Step: ${stepLabels[step] || `step ${step}`}
|
content: `Step: ${stepLabels[step] || `step ${step}`}
|
||||||
Channel: ${channel}
|
Channel: ${channel}
|
||||||
Current variant A:
|
Current variant A:
|
||||||
---
|
---
|
||||||
${existing.body}
|
${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) {
|
try {
|
||||||
return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 })
|
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 {
|
if (!parsed || !parsed.body) {
|
||||||
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 },
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
error: `AI generated a message but it was missing required information (${lastError}). Try again.`,
|
||||||
variant: {
|
}, { status: 422 })
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ────────────────────────────────────
|
// ─── REWRITE ────────────────────────────────────
|
||||||
if (action === "rewrite") {
|
if (action === "rewrite") {
|
||||||
const { instruction, currentBody } = body
|
const { instruction, currentBody, step } = body
|
||||||
const channel = body.channel
|
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> = {
|
const channelHints: Record<string, string> = {
|
||||||
whatsapp: "WhatsApp formatting (*bold*, _italic_, `code`). Emojis OK. Conversational.",
|
whatsapp: "WhatsApp formatting (*bold*, _italic_, `code`). Emojis OK. Conversational.",
|
||||||
email: "Email body (plain text). Can include links like {{pledge_url}}.",
|
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([
|
const result = await chat([
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `You rewrite UK charity fundraising messages. Follow the instruction exactly. Keep ALL {{variable}} placeholders. ${channelHints[channel] || ""}
|
content: `You rewrite UK charity fundraising messages. Follow the instruction exactly.
|
||||||
Return ONLY the rewritten message text — nothing else. No JSON, no explanation.`
|
|
||||||
|
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",
|
role: "user",
|
||||||
@@ -214,7 +309,7 @@ Current message:
|
|||||||
${currentBody}
|
${currentBody}
|
||||||
---
|
---
|
||||||
|
|
||||||
Rewrite it following the instruction.`
|
Rewrite it following the instruction. MUST include: ${requiredStr}`
|
||||||
}
|
}
|
||||||
], 600)
|
], 600)
|
||||||
|
|
||||||
@@ -222,7 +317,20 @@ Rewrite it following the instruction.`
|
|||||||
return NextResponse.json({ error: "AI unavailable" }, { status: 503 })
|
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 ──────────────────────────────
|
// ─── CHECK WINNERS ──────────────────────────────
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ export async function GET() {
|
|||||||
body: t.body,
|
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
|
// Seed config if none exists
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { sendPledgeReminder, isWhatsAppReady } from "@/lib/whatsapp"
|
import { sendWhatsAppMessage, isWhatsAppReady } from "@/lib/whatsapp"
|
||||||
import { generateReminderContent } from "@/lib/reminders"
|
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
|
* Call this via cron every 15 minutes: GET /api/cron/reminders?key=SECRET
|
||||||
*
|
*
|
||||||
* Sends reminders that are:
|
* Two jobs:
|
||||||
* 1. status = "pending"
|
* 1. Normal reminders (Reminder table, status=pending, scheduledAt <= now)
|
||||||
* 2. scheduledAt <= now
|
* 2. Due date messages (Pledge.dueDate = today, reminderSentForDueDate = false)
|
||||||
* 3. pledge is not paid/cancelled
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
// Simple auth via query param or header
|
// Simple auth via query param or header
|
||||||
@@ -26,8 +26,119 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const whatsappReady = await isWhatsAppReady()
|
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({
|
const dueReminders = await prisma.reminder.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@@ -40,50 +151,95 @@ export async function GET(request: NextRequest) {
|
|||||||
pledge: {
|
pledge: {
|
||||||
include: {
|
include: {
|
||||||
event: { select: { name: true } },
|
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,
|
paymentInstruction: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: 50, // Process in batches
|
take: 50,
|
||||||
orderBy: { scheduledAt: "asc" },
|
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) {
|
for (const reminder of dueReminders) {
|
||||||
const pledge = reminder.pledge
|
const pledge = reminder.pledge
|
||||||
const phone = pledge.donorPhone
|
const phone = pledge.donorPhone
|
||||||
const email = pledge.donorEmail
|
const email = pledge.donorEmail
|
||||||
const channel = reminder.channel
|
const channel = reminder.channel
|
||||||
const daysSince = Math.floor((now.getTime() - pledge.createdAt.getTime()) / 86400000)
|
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 {
|
try {
|
||||||
// WhatsApp channel — only if donor consented
|
// ── WhatsApp ──
|
||||||
if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) {
|
if (channel === "whatsapp" && phone && whatsappReady && pledge.whatsappOptIn) {
|
||||||
const result = await sendPledgeReminder(phone, {
|
// Try to use org's custom template + A/B variant selection
|
||||||
donorName: pledge.donorName || undefined,
|
const orgTemplates = await prisma.messageTemplate.findMany({
|
||||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
where: {
|
||||||
eventName: pledge.event.name,
|
organizationId: pledge.organizationId,
|
||||||
reference: pledge.reference,
|
step: reminder.step,
|
||||||
daysSincePledge: daysSince,
|
channel: "whatsapp",
|
||||||
step: reminder.step,
|
isActive: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
let selectedTemplate = orgTemplates.find(t => t.variant === "A")
|
||||||
await prisma.reminder.update({
|
let messageText: string
|
||||||
where: { id: reminder.id },
|
|
||||||
data: { status: "sent", sentAt: now },
|
// A/B variant selection based on splitPercent
|
||||||
})
|
if (orgTemplates.length > 1) {
|
||||||
sent++
|
const variantB = orgTemplates.find(t => t.variant === "B")
|
||||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp" })
|
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 {
|
} else {
|
||||||
// Try email fallback
|
// Fallback to hardcoded templates
|
||||||
if (email) {
|
const { sendPledgeReminder } = await import("@/lib/whatsapp")
|
||||||
// For now, mark as sent (email integration is external via webhook API)
|
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({
|
await prisma.reminder.update({
|
||||||
where: { id: reminder.id },
|
where: { id: reminder.id },
|
||||||
data: { status: "sent", sentAt: now, payload: { ...(reminder.payload as object || {}), fallback: "email", waError: result.error } },
|
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++
|
failed++
|
||||||
results.push({ id: reminder.id, status: "failed", channel: "whatsapp", error: result.error })
|
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) {
|
else if (channel === "email" && email && pledge.emailOptIn) {
|
||||||
// Generate content and store for external pickup
|
|
||||||
const payload = reminder.payload as Record<string, string> || {}
|
const payload = reminder.payload as Record<string, string> || {}
|
||||||
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
const cancelUrl = `${baseUrl}/p/cancel?ref=${pledge.reference}`
|
||||||
const cancelUrl = `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/cancel?ref=${pledge.reference}`
|
|
||||||
|
|
||||||
const content = generateReminderContent(payload.templateKey || "gentle_nudge", {
|
// Try custom email template
|
||||||
donorName: pledge.donorName || undefined,
|
const emailTemplate = await prisma.messageTemplate.findFirst({
|
||||||
amount: (pledge.amountPence / 100).toFixed(0),
|
where: {
|
||||||
reference: pledge.reference,
|
organizationId: pledge.organizationId,
|
||||||
eventName: pledge.event.name,
|
step: reminder.step,
|
||||||
bankName: bankDetails?.bankName,
|
channel: "email",
|
||||||
sortCode: bankDetails?.sortCode,
|
isActive: true,
|
||||||
accountNo: bankDetails?.accountNo,
|
variant: "A",
|
||||||
accountName: bankDetails?.accountName,
|
},
|
||||||
pledgeUrl: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/p/my-pledges`,
|
|
||||||
cancelUrl,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Try WhatsApp as fallback if phone exists and WhatsApp is ready
|
let subject: string
|
||||||
if (phone && whatsappReady && pledge.whatsappOptIn) {
|
let body: string
|
||||||
const waResult = await sendPledgeReminder(phone, {
|
|
||||||
|
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,
|
donorName: pledge.donorName || undefined,
|
||||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
amount: vars.amount,
|
||||||
eventName: pledge.event.name,
|
|
||||||
reference: pledge.reference,
|
reference: pledge.reference,
|
||||||
daysSincePledge: daysSince,
|
eventName: pledge.event.name,
|
||||||
step: reminder.step,
|
bankName: bankDetails?.bankName,
|
||||||
|
sortCode: bankDetails?.sortCode,
|
||||||
|
accountNo: bankDetails?.accountNo,
|
||||||
|
accountName: bankDetails?.accountName,
|
||||||
|
pledgeUrl: `${baseUrl}/p/my-pledges`,
|
||||||
|
cancelUrl,
|
||||||
})
|
})
|
||||||
if (waResult.success) {
|
subject = content.subject
|
||||||
await prisma.reminder.update({
|
body = content.body
|
||||||
where: { id: reminder.id },
|
}
|
||||||
data: { status: "sent", sentAt: now, payload: { ...payload, deliveredVia: "whatsapp-fallback" } },
|
|
||||||
})
|
// Try WhatsApp as fallback if phone exists
|
||||||
sent++
|
if (phone && whatsappReady && pledge.whatsappOptIn) {
|
||||||
results.push({ id: reminder.id, status: "sent", channel: "whatsapp-fallback" })
|
const waTemplate = await prisma.messageTemplate.findFirst({
|
||||||
continue
|
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
|
// Queue email
|
||||||
// Staff can see these in dashboard and send manually, or external tools can pick up via webhook
|
|
||||||
await prisma.reminder.update({
|
await prisma.reminder.update({
|
||||||
where: { id: reminder.id },
|
where: { id: reminder.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -146,15 +362,22 @@ export async function GET(request: NextRequest) {
|
|||||||
sentAt: now,
|
sentAt: now,
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload,
|
||||||
generatedSubject: content.subject,
|
generatedSubject: subject,
|
||||||
generatedBody: content.body,
|
generatedBody: body,
|
||||||
recipientEmail: email,
|
recipientEmail: email,
|
||||||
cancelUrl,
|
cancelUrl,
|
||||||
deliveredVia: "email-queued",
|
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++
|
sent++
|
||||||
results.push({ id: reminder.id, status: "sent", channel: "email-queued" })
|
results.push({ id: reminder.id, status: "sent", channel: "email-queued" })
|
||||||
}
|
}
|
||||||
@@ -175,8 +398,9 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
processed: dueReminders.length,
|
processed: dueReminders.length + dueDatePledges.length,
|
||||||
sent,
|
sent,
|
||||||
|
dueDateSent,
|
||||||
skipped,
|
skipped,
|
||||||
failed,
|
failed,
|
||||||
whatsappReady,
|
whatsappReady,
|
||||||
|
|||||||
@@ -4,26 +4,11 @@ import { useState, useEffect, useCallback, useRef } from "react"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import {
|
import {
|
||||||
Loader2, Check, Send, Sparkles, Trophy, CheckCheck,
|
Loader2, Check, Send, Sparkles, Trophy, CheckCheck,
|
||||||
ChevronDown, Clock, MessageCircle
|
ChevronDown, Clock, MessageCircle, RefreshCw, Calendar
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { resolvePreview, STEP_META } from "@/lib/templates"
|
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 {
|
interface Template {
|
||||||
id: string; step: number; channel: string; variant: string
|
id: string; step: number; channel: string; variant: string
|
||||||
name: string; subject: string | null; body: string
|
name: string; subject: string | null; body: string
|
||||||
@@ -53,6 +38,7 @@ export default function AutomationsPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState<number | null>(null)
|
const [saved, setSaved] = useState<number | null>(null)
|
||||||
const [aiWorking, setAiWorking] = useState(false)
|
const [aiWorking, setAiWorking] = useState(false)
|
||||||
|
const [regenerating, setRegenerating] = useState<number | null>(null)
|
||||||
const [showTiming, setShowTiming] = useState(false)
|
const [showTiming, setShowTiming] = useState(false)
|
||||||
|
|
||||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
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.channel === "whatsapp" && t.variant === variant)
|
||||||
|| templates.find(t => t.step === step && t.variant === variant)
|
|| templates.find(t => t.step === step && t.variant === variant)
|
||||||
|
|
||||||
const testsRunning = STEP_META.filter((_, i) => !!tpl(i, "B")).length
|
// Count A/B tests across the 5 message steps
|
||||||
const stepsWithoutTest = STEP_META.filter((_, i) => !tpl(i, "B")).length
|
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 neverOptimised = testsRunning === 0 && templates.every(t => t.variant === "A")
|
||||||
|
|
||||||
const optimiseAll = async () => {
|
const optimiseAll = async () => {
|
||||||
setAiWorking(true)
|
setAiWorking(true)
|
||||||
for (let step = 0; step < 4; step++) {
|
for (const step of allSteps) {
|
||||||
if (tpl(step, "B")) continue
|
if (tpl(step, "B")) continue
|
||||||
|
if (!tpl(step, "A")) continue
|
||||||
try {
|
try {
|
||||||
await fetch("/api/automations/ai", {
|
await fetch("/api/automations/ai", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
@@ -94,6 +83,25 @@ export default function AutomationsPage() {
|
|||||||
setAiWorking(false)
|
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 () => {
|
const pickWinnersAndContinue = async () => {
|
||||||
setAiWorking(true)
|
setAiWorking(true)
|
||||||
try {
|
try {
|
||||||
@@ -154,7 +162,7 @@ export default function AutomationsPage() {
|
|||||||
What your donors receive
|
What your donors receive
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,12 +173,7 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
{/* ━━ 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.
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
|
||||||
{neverOptimised ? (
|
{neverOptimised ? (
|
||||||
<div className="grid md:grid-cols-5 gap-0">
|
<div className="grid md:grid-cols-5 gap-0">
|
||||||
{/* Photo — the moment a reminder lands */}
|
{/* Photo — the moment a reminder lands */}
|
||||||
@@ -207,7 +210,6 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : testsRunning > 0 ? (
|
) : testsRunning > 0 ? (
|
||||||
/* ── Compact hero: tests running ── */
|
|
||||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<Sparkles className="h-5 w-5 text-[#60A5FA]" />
|
<Sparkles className="h-5 w-5 text-[#60A5FA]" />
|
||||||
@@ -225,7 +227,6 @@ export default function AutomationsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Compact hero: optimised ── */
|
|
||||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||||
<Trophy className="h-5 w-5 text-[#16A34A] shrink-0" />
|
<Trophy className="h-5 w-5 text-[#16A34A] shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -241,10 +242,7 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||||
The WhatsApp mockup uses rounded corners because it IS a
|
|
||||||
phone. Everything else follows brand rules (sharp edges).
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-lg mx-auto">
|
||||||
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
<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={{
|
<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")`,
|
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 a = tpl(step, "A")
|
||||||
const b = tpl(step, "B")
|
const b = tpl(step, "B")
|
||||||
const isEditing = editing === step
|
const isEditing = editing === step
|
||||||
const justSaved = saved === step
|
const justSaved = saved === step
|
||||||
const delay = delays[step]
|
const isRegenning = regenerating === step
|
||||||
const previewA = a ? resolvePreview(a.body) : ""
|
const previewA = a ? resolvePreview(a.body) : ""
|
||||||
const previewB = b ? resolvePreview(b.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 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 rateB = b && b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0
|
||||||
const totalSent = (a?.sentCount || 0) + (b?.sentCount || 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 hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE
|
||||||
const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null
|
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 (
|
return (
|
||||||
<div key={step}>
|
<div key={step}>
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
<div className="flex justify-center mb-3">
|
<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" }}>
|
<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" }}>
|
||||||
{step === 0 ? "Instantly" : `Day ${delay} · if not paid`}
|
{isConditional && <Calendar className="h-2.5 w-2.5" />}
|
||||||
|
{timeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
|
/* ── EDITING STATE ── */
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<div className="bg-[#DCF8C6] max-w-[90%] w-full shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
<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)}
|
<textarea ref={editorRef} value={editBody} onChange={e => setEditBody(e.target.value)}
|
||||||
@@ -305,6 +322,7 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : b ? (
|
) : b ? (
|
||||||
|
/* ── A/B TEST STATE ── */
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
<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" }}>
|
<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>}
|
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-px bg-[#075E54]/20">
|
<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">
|
<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">
|
<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>
|
<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>
|
<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>}
|
{a && a.sentCount > 0 && <p className="text-[8px] text-[#667781] mt-1">{a.convertedCount}/{a.sentCount} paid</p>}
|
||||||
</button>
|
</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">
|
<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>
|
<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>}
|
{b.sentCount > 0 && <span className={`text-[9px] font-bold ml-auto ${winner === "B" ? "text-[#075E54]" : "text-[#667781]"}`}>{rateB}%{winner === "B" && " 🏆"}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] leading-[1.4] text-[#303030] line-clamp-4"><WhatsAppFormatted text={previewB} /></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>}
|
{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>
|
</div>
|
||||||
<div className="bg-white/60 px-3 py-1.5" style={{ borderRadius: "0 0 8px 8px" }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* ── NORMAL MESSAGE ── */
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={() => startEdit(step)}
|
<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"
|
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} />
|
<WhatsAppFormatted text={previewA} />
|
||||||
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
<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]" />
|
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -395,7 +429,7 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Timing (expandable, in brand language) ── */}
|
{/* ── Timing ── */}
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-lg mx-auto">
|
||||||
<button onClick={() => setShowTiming(!showTiming)}
|
<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">
|
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
|
* {{account_no}} — account number
|
||||||
* {{org_name}} — charity name
|
* {{org_name}} — charity name
|
||||||
* {{days}} — days since pledge
|
* {{days}} — days since pledge
|
||||||
|
* {{due_date}} — formatted due date e.g. "Friday 14 March"
|
||||||
* {{cancel_url}} — link to cancel pledge
|
* {{cancel_url}} — link to cancel pledge
|
||||||
* {{pledge_url}} — link to view pledges
|
* {{pledge_url}} — link to view pledges
|
||||||
*/
|
*/
|
||||||
@@ -26,6 +27,93 @@ export interface TemplateDefaults {
|
|||||||
body: string
|
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 ──────────────────────────────────────
|
// ─── WhatsApp templates ──────────────────────────────────────
|
||||||
|
|
||||||
const WA_RECEIPT = `🤲 *Pledge Confirmed!*
|
const WA_RECEIPT = `🤲 *Pledge Confirmed!*
|
||||||
@@ -47,6 +135,22 @@ Reference: \`{{reference}}\`
|
|||||||
|
|
||||||
Reply *HELP* anytime 💚`
|
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}} 👋
|
const WA_GENTLE = `Hi {{name}} 👋
|
||||||
|
|
||||||
Just a quick reminder about your *£{{amount}}* pledge to {{event}}.
|
Just a quick reminder about your *£{{amount}}* pledge to {{event}}.
|
||||||
@@ -99,6 +203,23 @@ Thank you for your generosity!
|
|||||||
|
|
||||||
{{org_name}}`
|
{{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}},
|
const EMAIL_GENTLE = `Hi {{name}},
|
||||||
|
|
||||||
Just a friendly reminder about your £{{amount}} pledge at {{event}}.
|
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_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_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.`
|
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 ────────────────────────────────────────────
|
// ─── All defaults ────────────────────────────────────────────
|
||||||
|
|
||||||
export const DEFAULT_TEMPLATES: TemplateDefaults[] = [
|
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: "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: "email", name: "Pledge receipt", subject: "Your £{{amount}} pledge — payment details", body: EMAIL_RECEIPT },
|
||||||
{ step: 0, channel: "sms", name: "Pledge receipt", body: SMS_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: Day 2 gentle reminder
|
||||||
{ step: 1, channel: "whatsapp", name: "Gentle reminder", body: WA_GENTLE },
|
{ 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 },
|
{ 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: "account_no", label: "Account number", example: "12345678" },
|
||||||
{ key: "org_name", label: "Charity name", example: "Al Furqan Mosque" },
|
{ key: "org_name", label: "Charity name", example: "Al Furqan Mosque" },
|
||||||
{ key: "days", label: "Days since pledge", example: "7" },
|
{ 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: "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" },
|
{ 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 ───────────────────────────────────────────
|
// ─── 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 = [
|
export const STEP_META = [
|
||||||
{ step: 0, trigger: "Instantly", label: "Receipt", desc: "Pledge confirmation with bank details", icon: "✉️" },
|
{ 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: 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: 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: "🔔" },
|
{ 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.",
|
desc: "Try WhatsApp first, then SMS, then Email. Most cost-effective.",
|
||||||
matrix: {
|
matrix: {
|
||||||
"0": ["whatsapp", "sms", "email"],
|
"0": ["whatsapp", "sms", "email"],
|
||||||
|
"4": ["whatsapp", "sms", "email"],
|
||||||
"1": ["whatsapp", "sms", "email"],
|
"1": ["whatsapp", "sms", "email"],
|
||||||
"2": ["whatsapp", "sms", "email"],
|
"2": ["whatsapp", "sms", "email"],
|
||||||
"3": ["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.",
|
desc: "Send via ALL available channels. Maximum reach for important messages.",
|
||||||
matrix: {
|
matrix: {
|
||||||
"0": ["whatsapp+email"],
|
"0": ["whatsapp+email"],
|
||||||
|
"4": ["whatsapp+email"],
|
||||||
"1": ["whatsapp"],
|
"1": ["whatsapp"],
|
||||||
"2": ["whatsapp+email"],
|
"2": ["whatsapp+email"],
|
||||||
"3": ["whatsapp+email+sms"],
|
"3": ["whatsapp+email+sms"],
|
||||||
@@ -248,6 +381,7 @@ export const STRATEGY_PRESETS: StrategyPreset[] = [
|
|||||||
desc: "Start with WhatsApp only, add channels as urgency increases.",
|
desc: "Start with WhatsApp only, add channels as urgency increases.",
|
||||||
matrix: {
|
matrix: {
|
||||||
"0": ["whatsapp", "email"],
|
"0": ["whatsapp", "email"],
|
||||||
|
"4": ["whatsapp"],
|
||||||
"1": ["whatsapp"],
|
"1": ["whatsapp"],
|
||||||
"2": ["whatsapp", "email"],
|
"2": ["whatsapp", "email"],
|
||||||
"3": ["whatsapp+email+sms"],
|
"3": ["whatsapp+email+sms"],
|
||||||
|
|||||||
Reference in New Issue
Block a user