Full messages visible, cron A/B wired, world-class templates, conversion tracking
THREE THINGS:
1. NO TRUNCATION — full messages always visible
- Removed line-clamp-4 from A/B test cards
- A/B variants now stack vertically (Yours on top, AI below)
- Both messages show in full — no eclipse, no hiding
- Text size increased to 12→13px for readability
- Stats show 'X% conversion · N/M' format
2. CRON FULLY WIRED for templates + A/B
- Due date messages now do A/B variant selection (was A-only)
- Template variant ID stored in Reminder.payload for attribution
- Conversion tracking: when pledge marked paid (manual, PAID keyword,
or bank match), find last sent reminder → increment convertedCount
on the template variant that drove the action
- WhatsApp PAID handler now also skips remaining reminders
3. WORLD-CLASS TEMPLATES — every word earns its place
Receipt: 'Jazākallāhu khayrā' opening → confirm → payment block →
'one transfer and you're done' → ref. Cultural resonance + zero friction.
Due date: 'Today's the day' → payment block → 'two minutes and it's done'.
Honour their commitment, don't nag.
Day 2 gentle: 5 lines total. 'Quick one' → pay link → ref → 'reply PAID'.
Maximum brevity. They're busy, not negligent.
Day 7 impact: 'Can make a real difference' → acknowledge busyness →
pay link → 'every pound counts'. Empathy + purpose.
Day 14 final: 'No pressure — we completely understand' →
✅ pay / ❌ cancel as equal options → 'jazākallāhu khayrā for your
intention'. Maximum respect. No guilt. Both options valid.
Design principles applied:
- Gratitude-first (reduces unsubscribes 60%)
- One CTA per message (never compete with yourself)
- Cultural markers (Salaam, Jazākallāhu khayrā)
- Specific > vague (amounts, refs, dates always visible)
- Brevity curve (long receipt → medium impact → short final)
This commit is contained in:
@@ -79,11 +79,22 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to use org's custom due date template (step 4)
|
||||
const customTemplate = await prisma.messageTemplate.findFirst({
|
||||
where: { organizationId: pledge.organizationId, step: 4, channel: "whatsapp", variant: "A" },
|
||||
// Try to use org's custom due date template (step 4) with A/B selection
|
||||
const dueDateTemplates = await prisma.messageTemplate.findMany({
|
||||
where: { organizationId: pledge.organizationId, step: 4, channel: "whatsapp", isActive: true },
|
||||
})
|
||||
|
||||
let customTemplate = dueDateTemplates.find(t => t.variant === "A") || null
|
||||
// A/B variant selection
|
||||
if (dueDateTemplates.length > 1) {
|
||||
const variantB = dueDateTemplates.find(t => t.variant === "B")
|
||||
if (variantB && customTemplate) {
|
||||
if (Math.random() * 100 < variantB.splitPercent) {
|
||||
customTemplate = variantB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bankDetails = pledge.paymentInstruction?.bankDetails as Record<string, string> | null
|
||||
const dueFormatted = pledge.dueDate
|
||||
? pledge.dueDate.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })
|
||||
@@ -316,7 +327,15 @@ export async function GET(request: NextRequest) {
|
||||
if (result.success) {
|
||||
await prisma.reminder.update({
|
||||
where: { id: reminder.id },
|
||||
data: { status: "sent", sentAt: now },
|
||||
data: {
|
||||
status: "sent", sentAt: now,
|
||||
payload: {
|
||||
...(reminder.payload as object || {}),
|
||||
templateId: selectedTemplate?.id,
|
||||
templateVariant: selectedTemplate?.variant,
|
||||
deliveredVia: "whatsapp",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Increment sentCount for A/B tracking
|
||||
|
||||
@@ -91,6 +91,23 @@ export async function PATCH(
|
||||
})
|
||||
}
|
||||
|
||||
// A/B conversion tracking: when paid, credit the template variant that was last sent
|
||||
if (parsed.data.status === "paid") {
|
||||
try {
|
||||
const lastSentReminder = await prisma.reminder.findFirst({
|
||||
where: { pledgeId: id, status: "sent" },
|
||||
orderBy: { sentAt: "desc" },
|
||||
})
|
||||
const payload = lastSentReminder?.payload as Record<string, string> | null
|
||||
if (payload?.templateId) {
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id: payload.templateId },
|
||||
data: { convertedCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
} catch { /* conversion tracking is best-effort */ }
|
||||
}
|
||||
|
||||
// Log activity
|
||||
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
|
||||
await logActivity({
|
||||
|
||||
@@ -91,6 +91,28 @@ export async function POST(request: NextRequest) {
|
||||
where: { id: pledge.id },
|
||||
data: { status: "initiated", iPaidClickedAt: new Date() },
|
||||
})
|
||||
|
||||
// A/B conversion tracking: credit the template variant that drove this action
|
||||
try {
|
||||
const lastReminder = await prisma.reminder.findFirst({
|
||||
where: { pledgeId: pledge.id, status: "sent" },
|
||||
orderBy: { sentAt: "desc" },
|
||||
})
|
||||
const payload = lastReminder?.payload as Record<string, string> | null
|
||||
if (payload?.templateId) {
|
||||
await prisma.messageTemplate.update({
|
||||
where: { id: payload.templateId },
|
||||
data: { convertedCount: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
// Skip remaining reminders
|
||||
await prisma.reminder.updateMany({
|
||||
where: { pledgeId: pledge.id, status: "pending" },
|
||||
data: { status: "skipped" },
|
||||
})
|
||||
|
||||
await sendWhatsAppMessage(fromPhone,
|
||||
`✅ Thanks! We've noted that you've paid your *£${amount}* pledge to ${pledge.event.name}.\n\nWe'll confirm once the payment is matched. Ref: \`${pledge.reference}\``
|
||||
)
|
||||
|
||||
@@ -61,7 +61,6 @@ export default function AutomationsPage() {
|
||||
templates.find(t => t.step === step && t.channel === "whatsapp" && t.variant === variant)
|
||||
|| templates.find(t => t.step === step && t.variant === variant)
|
||||
|
||||
// Count A/B tests across the 5 message steps
|
||||
const allSteps = STEP_META.map(m => m.step)
|
||||
const testsRunning = allSteps.filter(s => !!tpl(s, "B")).length
|
||||
const stepsWithoutTest = allSteps.filter(s => tpl(s, "A") && !tpl(s, "B")).length
|
||||
@@ -83,16 +82,13 @@ export default function AutomationsPage() {
|
||||
setAiWorking(false)
|
||||
}
|
||||
|
||||
/** Regenerate a single AI variant — replaces the existing B with a fresh attempt */
|
||||
const regenerateVariant = async (step: number) => {
|
||||
setRegenerating(step)
|
||||
try {
|
||||
// Delete existing B first
|
||||
await fetch("/api/automations", {
|
||||
method: "DELETE", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step, channel: "whatsapp", variant: "B" }),
|
||||
})
|
||||
// Generate a new one
|
||||
await fetch("/api/automations/ai", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate_variant", step, channel: "whatsapp" }),
|
||||
@@ -176,17 +172,13 @@ export default function AutomationsPage() {
|
||||
{/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
{neverOptimised ? (
|
||||
<div className="grid md:grid-cols-5 gap-0">
|
||||
{/* Photo — the moment a reminder lands */}
|
||||
<div className="md:col-span-2 relative min-h-[200px] md:min-h-[280px] overflow-hidden">
|
||||
<Image
|
||||
src="/images/brand/digital-03-notification-smile.jpg"
|
||||
alt="Young man at a London bus stop smiling at his phone — the moment a gentle WhatsApp reminder lands"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 40vw"
|
||||
fill className="object-cover" sizes="(max-width: 768px) 100vw, 40vw"
|
||||
/>
|
||||
</div>
|
||||
{/* Dark panel — CTA */}
|
||||
<div className="md:col-span-3 bg-[#111827] p-8 md:p-10 flex flex-col justify-center">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="h-4 w-4 text-[#60A5FA]" />
|
||||
@@ -203,8 +195,7 @@ export default function AutomationsPage() {
|
||||
className="mt-6 inline-flex items-center justify-center bg-white px-6 py-3 text-sm font-bold text-[#111827] hover:bg-gray-100 transition-colors self-start disabled:opacity-60">
|
||||
{aiWorking
|
||||
? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> AI is writing new versions…</>
|
||||
: <><Sparkles className="h-4 w-4 mr-2" /> Start optimising</>
|
||||
}
|
||||
: <><Sparkles className="h-4 w-4 mr-2" /> Start optimising</>}
|
||||
</button>
|
||||
<p className="text-[11px] text-gray-500 mt-3">Uses GPT-4.1 nano · Costs less than 1p per message</p>
|
||||
</div>
|
||||
@@ -242,7 +233,10 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Full messages always visible. No truncation. No eclipse.
|
||||
A/B tests stack vertically — Yours on top, AI below.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
||||
|
||||
@@ -272,11 +266,9 @@ export default function AutomationsPage() {
|
||||
const previewA = a ? resolvePreview(a.body) : ""
|
||||
const previewB = b ? resolvePreview(b.body) : ""
|
||||
|
||||
// Due date step: show conditional label
|
||||
const isConditional = meta.conditional
|
||||
const hasDueDateTemplate = !!a
|
||||
|
||||
// Timing labels
|
||||
const timeLabel = step === 0 ? "Instantly" :
|
||||
step === 4 ? "On the due date · if set" :
|
||||
step === 1 ? `Day ${delays[1]} · if not paid` :
|
||||
@@ -292,7 +284,6 @@ export default function AutomationsPage() {
|
||||
const hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE
|
||||
const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null
|
||||
|
||||
// Don't render the due date step if no template exists yet
|
||||
if (isConditional && !hasDueDateTemplate) return null
|
||||
|
||||
return (
|
||||
@@ -306,25 +297,26 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
/* ── EDITING STATE ── */
|
||||
/* ── EDITING ── */
|
||||
<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)}
|
||||
className="w-full bg-transparent px-3 py-2 text-[12px] leading-[1.5] text-[#303030] resize-y outline-none min-h-[120px] font-mono" />
|
||||
className="w-full bg-transparent px-3 py-2 text-[13px] leading-[1.6] text-[#303030] resize-y outline-none min-h-[180px] font-mono" />
|
||||
<div className="px-3 pb-2 flex items-center gap-2">
|
||||
<button onClick={() => saveEdit(step)} disabled={saving}
|
||||
className="bg-[#075E54] text-white px-3 py-1.5 text-[10px] font-bold flex items-center gap-1 disabled:opacity-50" style={{ borderRadius: "4px" }}>
|
||||
{saving ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Check className="h-2.5 w-2.5" />} Save
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} className="text-[10px] text-[#667781]">Cancel</button>
|
||||
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{reference}}"}</span>
|
||||
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{payment_block}}"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : b ? (
|
||||
/* ── A/B TEST STATE ── */
|
||||
/* ── A/B TEST — FULL MESSAGES, STACKED VERTICALLY ── */
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
||||
{/* Header bar */}
|
||||
<div className="bg-[#075E54] text-white px-3 py-1.5 flex items-center gap-1.5" style={{ borderRadius: "8px 8px 0 0" }}>
|
||||
<Sparkles className="h-3 w-3 text-[#60A5FA]" />
|
||||
<span className="text-[10px] font-bold flex-1">AI is testing</span>
|
||||
@@ -335,56 +327,63 @@ 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>
|
||||
{a && a.sentCount > 0 && <span className={`text-[9px] font-bold ml-auto ${winner === "A" ? "text-[#075E54]" : "text-[#667781]"}`}>{rateA}%{winner === "A" && " 🏆"}</span>}
|
||||
</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>}
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
{/* Variant A — Yours (full message) */}
|
||||
<button onClick={() => startEdit(step)} className="w-full bg-[#DCF8C6] p-3 text-left hover:brightness-[0.97] transition-all border-b border-[#075E54]/10">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] font-bold text-[#075E54] bg-[#075E54]/10 px-1.5 py-0.5">Yours</span>
|
||||
{a && a.sentCount > 0 && (
|
||||
<span className={`text-[9px] font-bold ml-auto ${winner === "A" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||
{rateA}% conversion{winner === "A" && " 🏆"} · {a.convertedCount}/{a.sentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.5] text-[#303030]">
|
||||
<WhatsAppFormatted text={previewA} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Variant B — AI (full message) */}
|
||||
<div className="bg-[#DCF8C6] p-3 relative group">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] 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}% conversion{winner === "B" && " 🏆"} · {b.convertedCount}/{b.sentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.5] text-[#303030]">
|
||||
<WhatsAppFormatted text={previewB} />
|
||||
</div>
|
||||
{/* Regenerate */}
|
||||
<button onClick={() => regenerateVariant(step)} disabled={isRegenning}
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 bg-white/90 p-1.5 shadow-sm transition-opacity hover:bg-white disabled:opacity-50"
|
||||
style={{ borderRadius: "4px" }} title="Try a different AI version">
|
||||
{isRegenning ? <Loader2 className="h-3.5 w-3.5 text-[#1E40AF] animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 text-[#1E40AF]" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="bg-white/60 px-3 py-1.5" style={{ borderRadius: "0 0 8px 8px" }}>
|
||||
<div className="h-1 bg-gray-200 overflow-hidden" style={{ borderRadius: "2px" }}>
|
||||
<div className={`h-full transition-all ${hasEnoughData ? "bg-[#16A34A]" : "bg-[#60A5FA]"}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-[8px] text-[#667781] mt-1">
|
||||
<p className="text-[9px] text-[#667781] mt-1">
|
||||
{hasEnoughData
|
||||
? winner ? `${winner === "B" ? "AI" : "Your"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call"
|
||||
: `${totalSent} of ${MIN_SAMPLE * 2} sends to verdict`}
|
||||
? winner ? `${winner === "B" ? "AI" : "Your"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call — need more data"
|
||||
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed for a verdict`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── NORMAL MESSAGE ── */
|
||||
/* ── NORMAL MESSAGE (full, no truncation) ── */
|
||||
<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"
|
||||
className="bg-[#DCF8C6] max-w-[88%] px-3 py-2 text-left text-[13px] leading-[1.5] text-[#303030] relative shadow-sm cursor-pointer hover:brightness-[0.97] transition-all"
|
||||
style={{ borderRadius: "8px 0 8px 8px" }}>
|
||||
{justSaved && (
|
||||
<div className="absolute -top-2 -right-2 w-5 h-5 bg-[#25D366] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
||||
@@ -423,8 +422,7 @@ export default function AutomationsPage() {
|
||||
className="w-full bg-[#111827] text-white py-3 text-sm font-bold flex items-center justify-center gap-2 hover:bg-gray-800 transition-colors disabled:opacity-50">
|
||||
{aiWorking
|
||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Picking winners…</>
|
||||
: <><Trophy className="h-3.5 w-3.5" /> Pick winners & start new round</>
|
||||
}
|
||||
: <><Trophy className="h-3.5 w-3.5" /> Pick winners & start new round</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -458,9 +456,6 @@ export default function AutomationsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ─── WhatsApp Formatted Text ────────────────────────────────
|
||||
|
||||
function WhatsAppFormatted({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
/**
|
||||
* Default message templates — seeded when an org first visits Automations.
|
||||
*
|
||||
* Templates use {{variable}} syntax, resolved at send time.
|
||||
* CONVERSION-OPTIMISED. Every word earns its place. Principles:
|
||||
* 1. First-name personalisation (+26% conversion)
|
||||
* 2. One clear CTA per message (never compete with yourself)
|
||||
* 3. Social proof where possible
|
||||
* 4. Gratitude-first framing (reduces unsubscribes 60%)
|
||||
* 5. Specific > vague (amounts, references, dates)
|
||||
* 6. Cultural resonance (Salaam, Islamic giving context)
|
||||
* 7. Brevity — WhatsApp is read on phones, not monitors
|
||||
*
|
||||
* UNIVERSAL CTA SYSTEM:
|
||||
* Every message MUST have a call-to-action. The CTA adapts to how the donor
|
||||
* chose to pay — bank transfer, external platform, card, or Direct Debit.
|
||||
*
|
||||
* Two smart CTA variables:
|
||||
*
|
||||
* {{payment_block}} — Full payment instruction block. Used in receipts
|
||||
* and due date messages. Renders as:
|
||||
* - Bank: sort code, account, reference with dividers
|
||||
* - External: link to LaunchGood/JustGiving/etc
|
||||
* - Card: link to Stripe checkout page
|
||||
* - GoCardless: "DD is set up, auto-collected"
|
||||
*
|
||||
* {{pay_link}} — Single URL to complete payment. Used in reminder CTAs.
|
||||
* Resolves to /p/pay?ref=XXX which adapts to any rail.
|
||||
* {{payment_block}} — Full payment instructions. Adapts to bank/external/card/DD.
|
||||
* {{pay_link}} — Single link to complete payment. Adapts per rail.
|
||||
*
|
||||
* Other variables:
|
||||
* {{name}} — donor first name (or "there")
|
||||
* {{amount}} — pledge amount e.g. "50"
|
||||
* {{event}} — appeal/event name
|
||||
* {{reference}} — payment reference e.g. "PNPL-A2F4-50"
|
||||
* {{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
|
||||
*
|
||||
* Legacy (still resolved if present, but templates should use {{payment_block}}):
|
||||
* {{bank_name}}, {{sort_code}}, {{account_no}}
|
||||
* {{name}} {{amount}} {{event}} {{reference}} {{org_name}}
|
||||
* {{days}} {{due_date}} {{cancel_url}} {{pledge_url}}
|
||||
* {{bank_name}} {{sort_code}} {{account_no}} — legacy, still resolved
|
||||
*/
|
||||
|
||||
export interface TemplateDefaults {
|
||||
@@ -43,7 +29,6 @@ export interface TemplateDefaults {
|
||||
}
|
||||
|
||||
// ─── 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", "payment_block"],
|
||||
@@ -53,7 +38,6 @@ export const REQUIRED_VARIABLES: Record<number, string[]> = {
|
||||
3: ["name", "amount", "reference", "pay_link"],
|
||||
}
|
||||
|
||||
// Variables that SHOULD be present (warn if missing, but don't reject)
|
||||
export const RECOMMENDED_VARIABLES: Record<number, string[]> = {
|
||||
0: ["org_name"],
|
||||
4: [],
|
||||
@@ -72,16 +56,13 @@ export function validateTemplate(step: number, body: 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.
|
||||
* Last resort — better than sending a message missing the CTA.
|
||||
*/
|
||||
export function patchMissingVariables(step: number, body: string): string {
|
||||
const { missing } = validateTemplate(step, body)
|
||||
@@ -89,37 +70,22 @@ export function patchMissingVariables(step: number, body: string): string {
|
||||
|
||||
let patched = body.trimEnd()
|
||||
|
||||
// Steps 0, 4 — need {{payment_block}}
|
||||
if ((step === 0 || step === 4) && missing.includes("payment_block")) {
|
||||
patched += "\n\n{{payment_block}}"
|
||||
}
|
||||
|
||||
// Steps 1-3 — need {{pay_link}}
|
||||
if ([1, 2, 3].includes(step) && missing.includes("pay_link")) {
|
||||
patched += "\n\nComplete your pledge: {{pay_link}}"
|
||||
}
|
||||
|
||||
// Step 4 — needs {{due_date}}
|
||||
if (step === 4 && missing.includes("due_date")) {
|
||||
patched += "\n\n📅 Due: *{{due_date}}*"
|
||||
}
|
||||
|
||||
// Generic: patch any still-missing basic vars
|
||||
const { missing: stillMissing } = validateTemplate(step, patched)
|
||||
if (stillMissing.length > 0) {
|
||||
const patches: Record<string, string> = {
|
||||
name: "Hi {{name}},\n\n",
|
||||
amount: "💷 *£{{amount}}*",
|
||||
reference: "\nRef: `{{reference}}`",
|
||||
event: " to *{{event}}*",
|
||||
}
|
||||
for (const v of stillMissing) {
|
||||
if (v === "name" && !patched.includes("{{name}}")) {
|
||||
patched = patches.name + patched
|
||||
} else if (patches[v]) {
|
||||
patched += patches[v]
|
||||
}
|
||||
}
|
||||
for (const v of stillMissing) {
|
||||
if (v === "name" && !patched.includes("{{name}}")) patched = "Hi {{name}},\n\n" + patched
|
||||
else if (v === "amount") patched += "\n💷 *£{{amount}}*"
|
||||
else if (v === "reference") patched += "\nRef: `{{reference}}`"
|
||||
else if (v === "event") patched += " to *{{event}}*"
|
||||
}
|
||||
|
||||
return patched
|
||||
@@ -127,10 +93,7 @@ export function patchMissingVariables(step: number, body: string): string {
|
||||
|
||||
/**
|
||||
* Build the payment block based on pledge context.
|
||||
* This is resolved at SEND TIME in the cron, not at template design time.
|
||||
*
|
||||
* The same template works for all payment rails because {{payment_block}}
|
||||
* adapts to the pledge's rail and event's payment mode.
|
||||
* Resolved at SEND TIME — same template works for every payment rail.
|
||||
*/
|
||||
export function buildPaymentBlock(context: {
|
||||
rail: string
|
||||
@@ -142,65 +105,44 @@ export function buildPaymentBlock(context: {
|
||||
bankName?: string
|
||||
externalUrl?: string
|
||||
externalPlatform?: string
|
||||
channel?: string // whatsapp, email, sms
|
||||
channel?: string
|
||||
}): string {
|
||||
const ch = context.channel || "whatsapp"
|
||||
|
||||
// ── External platform (LaunchGood, JustGiving, etc.) ──
|
||||
// External platform (LaunchGood, JustGiving, etc.)
|
||||
if (context.paymentMode === "external" && context.externalUrl) {
|
||||
const platformNames: Record<string, string> = {
|
||||
launchgood: "LaunchGood",
|
||||
justgiving: "JustGiving",
|
||||
gofundme: "GoFundMe",
|
||||
enthuse: "Enthuse",
|
||||
}
|
||||
const platform = platformNames[context.externalPlatform || ""] || "the donation page"
|
||||
|
||||
if (ch === "whatsapp") {
|
||||
return `Complete your donation on *${platform}*:\n🔗 ${context.externalUrl}\n\n_Use reference \`${context.reference}\` if asked_`
|
||||
}
|
||||
if (ch === "sms") {
|
||||
return `Donate at ${context.externalUrl} ref ${context.reference}`
|
||||
const names: Record<string, string> = {
|
||||
launchgood: "LaunchGood", justgiving: "JustGiving",
|
||||
gofundme: "GoFundMe", enthuse: "Enthuse",
|
||||
}
|
||||
const platform = names[context.externalPlatform || ""] || "the donation page"
|
||||
if (ch === "whatsapp") return `Complete your donation on *${platform}*:\n🔗 ${context.externalUrl}\n\n_Use reference \`${context.reference}\` if asked_`
|
||||
if (ch === "sms") return `Donate at ${context.externalUrl} ref ${context.reference}`
|
||||
return `Complete your donation on ${platform}:\n${context.externalUrl}\n\nUse reference: ${context.reference}`
|
||||
}
|
||||
|
||||
// ── GoCardless (Direct Debit) ──
|
||||
// GoCardless (Direct Debit)
|
||||
if (context.rail === "gocardless") {
|
||||
if (ch === "whatsapp") {
|
||||
return `✅ *Direct Debit is set up*\n_Payment will be collected automatically in 3-5 working days_\n_Protected by the Direct Debit Guarantee_`
|
||||
}
|
||||
if (ch === "sms") {
|
||||
return `DD set up - payment collected automatically in 3-5 days.`
|
||||
}
|
||||
if (ch === "whatsapp") return `✅ *Direct Debit is set up*\n_Payment will be collected automatically in 3-5 working days_\n_Protected by the Direct Debit Guarantee_`
|
||||
if (ch === "sms") return `DD set up - payment collected automatically in 3-5 days.`
|
||||
return `✅ Direct Debit is set up\nPayment will be collected automatically in 3-5 working days.\nProtected by the Direct Debit Guarantee.`
|
||||
}
|
||||
|
||||
// ── Card (Stripe) ──
|
||||
// Card (Stripe)
|
||||
if (context.rail === "card") {
|
||||
if (ch === "whatsapp") {
|
||||
return `Pay by card:\n🔗 ${context.payLink}\n\n_Secure payment via Stripe · Visa, Mastercard, Apple Pay_`
|
||||
}
|
||||
if (ch === "sms") {
|
||||
return `Pay by card: ${context.payLink}`
|
||||
}
|
||||
if (ch === "whatsapp") return `Pay by card:\n🔗 ${context.payLink}\n\n_Secure payment via Stripe · Visa, Mastercard, Apple Pay_`
|
||||
if (ch === "sms") return `Pay by card: ${context.payLink}`
|
||||
return `Pay by card:\n${context.payLink}\n\nSecure payment via Stripe — Visa, Mastercard, Amex, Apple Pay, Google Pay.`
|
||||
}
|
||||
|
||||
// ── Bank transfer (default) ──
|
||||
if (ch === "whatsapp") {
|
||||
return `━━━━━━━━━━━━━━━━━━\n*Transfer to:*\nSort Code: \`${context.sortCode || "N/A"}\`\nAccount: \`${context.accountNo || "N/A"}\`\nName: ${context.bankName || "N/A"}\nReference: \`${context.reference}\`\n━━━━━━━━━━━━━━━━━━\n\n⚠️ _Use the exact reference above_`
|
||||
}
|
||||
if (ch === "sms") {
|
||||
return `SC ${context.sortCode || "N/A"} Acc ${context.accountNo || "N/A"} Name ${context.bankName || "N/A"} Ref ${context.reference}`
|
||||
}
|
||||
// Bank transfer (default)
|
||||
if (ch === "whatsapp") return `━━━━━━━━━━━━━━━━━━\n*Transfer to:*\nSort Code: \`${context.sortCode || "N/A"}\`\nAccount: \`${context.accountNo || "N/A"}\`\nName: ${context.bankName || "N/A"}\nReference: \`${context.reference}\`\n━━━━━━━━━━━━━━━━━━\n\n⚠️ _Use the exact reference above_`
|
||||
if (ch === "sms") return `SC ${context.sortCode || "N/A"} Acc ${context.accountNo || "N/A"} Name ${context.bankName || "N/A"} Ref ${context.reference}`
|
||||
return `Bank: ${context.bankName || "N/A"}\nSort Code: ${context.sortCode || "N/A"}\nAccount: ${context.accountNo || "N/A"}\nReference: ${context.reference}\n\n⚠️ Use the exact reference above so we can match your payment.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the pay link for a pledge.
|
||||
* For external platforms, links directly to their URL.
|
||||
* For everything else, links to /p/pay which adapts.
|
||||
*/
|
||||
export function computePayLink(context: {
|
||||
paymentMode: string
|
||||
@@ -208,181 +150,193 @@ export function computePayLink(context: {
|
||||
reference: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
if (context.paymentMode === "external" && context.externalUrl) {
|
||||
return context.externalUrl
|
||||
}
|
||||
const base = context.baseUrl || "https://pledge.quikcue.com"
|
||||
return `${base}/p/pay?ref=${context.reference}`
|
||||
if (context.paymentMode === "external" && context.externalUrl) return context.externalUrl
|
||||
return `${context.baseUrl || "https://pledge.quikcue.com"}/p/pay?ref=${context.reference}`
|
||||
}
|
||||
|
||||
|
||||
// ─── WhatsApp templates ──────────────────────────────────────
|
||||
// Note: {{payment_block}} renders the right CTA for any rail.
|
||||
// No hardcoded bank details — adapts to external, card, DD, or bank.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// WORLD-CLASS TEMPLATES — OPTIMISED FOR PLEDGE CONVERSION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const WA_RECEIPT = `🤲 *Pledge Confirmed!*
|
||||
// ─── Step 0: RECEIPT ─────────────────────────────────────────
|
||||
// Peak emotional engagement. They JUST committed.
|
||||
// Job: make paying feel effortless. Zero friction to action.
|
||||
|
||||
Thank you, {{name}}!
|
||||
const WA_RECEIPT = `Jazākallāhu khayrā, {{name}} 🤲
|
||||
|
||||
💷 *£{{amount}}* pledged to *{{event}}*
|
||||
🔖 Ref: \`{{reference}}\`
|
||||
Your *£{{amount}}* pledge to *{{event}}* is confirmed.
|
||||
|
||||
{{payment_block}}
|
||||
|
||||
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}}\`
|
||||
|
||||
{{payment_block}}
|
||||
|
||||
Already sorted? Reply *PAID* 🙏
|
||||
Need help? Reply *HELP*`
|
||||
|
||||
const WA_GENTLE = `Hi {{name}} 👋
|
||||
|
||||
Just a quick reminder about your *£{{amount}}* pledge to {{event}}.
|
||||
|
||||
If you've already paid — thank you! 🙏
|
||||
If not, complete your pledge here: {{pay_link}}
|
||||
That's it — one transfer and you're done.
|
||||
|
||||
Ref: \`{{reference}}\`
|
||||
Reply *HELP* anytime · {{org_name}} 💚`
|
||||
|
||||
Reply *PAID* if you've sent it, or *HELP* if you need anything.`
|
||||
const EMAIL_RECEIPT = `Jazākallāhu khayrā, {{name}}.
|
||||
|
||||
const WA_IMPACT = `Hi {{name}},
|
||||
|
||||
Your *£{{amount}}* pledge to {{event}} is still pending ({{days}} days).
|
||||
|
||||
Every pound makes a real difference. 🤲
|
||||
|
||||
Complete your pledge: {{pay_link}}
|
||||
Ref: \`{{reference}}\`
|
||||
|
||||
Reply *PAID* once done, or *CANCEL* to withdraw.`
|
||||
|
||||
const WA_FINAL = `Hi {{name}},
|
||||
|
||||
This is our final message about your *£{{amount}}* pledge to {{event}}.
|
||||
|
||||
We completely understand if circumstances have changed.
|
||||
|
||||
Complete your pledge: {{pay_link}}
|
||||
|
||||
Or reply:
|
||||
*PAID* — if you've already sent it
|
||||
*CANCEL* — to withdraw the pledge
|
||||
*HELP* — if you need assistance
|
||||
|
||||
Ref: \`{{reference}}\``
|
||||
|
||||
// ─── Email templates ─────────────────────────────────────────
|
||||
|
||||
const EMAIL_RECEIPT = `Hi {{name}},
|
||||
|
||||
Thank you for pledging £{{amount}} at {{event}}!
|
||||
Your pledge of £{{amount}} to {{event}} is confirmed.
|
||||
|
||||
To complete your donation:
|
||||
|
||||
{{payment_block}}
|
||||
|
||||
View your pledge: {{pledge_url}}
|
||||
Reference: {{reference}}
|
||||
|
||||
Thank you for your generosity!
|
||||
One transfer and you're done. View your pledge anytime: {{pledge_url}}
|
||||
|
||||
{{org_name}}`
|
||||
|
||||
const EMAIL_DUE_DATE = `Hi {{name}},
|
||||
const SMS_RECEIPT = `JazakAllah {{name}}! £{{amount}} pledged to {{event}}. Ref {{reference}}. {{payment_block}}`
|
||||
|
||||
Your £{{amount}} pledge to {{event}} is due today ({{due_date}}).
|
||||
|
||||
// ─── Step 4: DUE DATE ────────────────────────────────────────
|
||||
// They chose today. Not nagging — honouring their commitment.
|
||||
// Job: acknowledge THEIR choice + remove all friction.
|
||||
|
||||
const WA_DUE_DATE = `Salaam {{name}} 👋
|
||||
|
||||
Today's the day — your *£{{amount}}* pledge to *{{event}}* is due ({{due_date}}).
|
||||
|
||||
{{payment_block}}
|
||||
|
||||
View your pledge: {{pledge_url}}
|
||||
Two minutes and it's done ✅
|
||||
|
||||
Already paid? You can ignore this message.
|
||||
Ref: \`{{reference}}\`
|
||||
Already sent it? Reply *PAID*`
|
||||
|
||||
const EMAIL_DUE_DATE = `Hi {{name}},
|
||||
|
||||
Today is the day you chose to complete your £{{amount}} pledge to {{event}} ({{due_date}}).
|
||||
|
||||
{{payment_block}}
|
||||
|
||||
Reference: {{reference}}
|
||||
|
||||
Two minutes and it's done.
|
||||
|
||||
Already paid? You can ignore this. View your pledge: {{pledge_url}}
|
||||
|
||||
{{org_name}}`
|
||||
|
||||
const SMS_DUE_DATE = `{{name}}, your £{{amount}} pledge to {{event}} is due today. Ref {{reference}}. {{payment_block}}`
|
||||
|
||||
|
||||
// ─── Step 1: GENTLE REMINDER (Day 2) ────────────────────────
|
||||
// They're busy, not negligent. Maximum brevity.
|
||||
// Job: one tap to the right place. Nothing else.
|
||||
|
||||
const WA_GENTLE = `Salaam {{name}} 👋
|
||||
|
||||
Quick one — your *£{{amount}}* pledge to *{{event}}* is waiting.
|
||||
|
||||
Pay now: {{pay_link}}
|
||||
Ref: \`{{reference}}\`
|
||||
|
||||
Already paid? Reply *PAID* 🙏`
|
||||
|
||||
const EMAIL_GENTLE = `Hi {{name}},
|
||||
|
||||
Just a friendly reminder about your £{{amount}} pledge at {{event}}.
|
||||
|
||||
If you've already sent the payment, thank you! It can take a few days to appear.
|
||||
Quick reminder — your £{{amount}} pledge to {{event}} is still pending.
|
||||
|
||||
Complete your pledge: {{pay_link}}
|
||||
|
||||
Reference: {{reference}}
|
||||
|
||||
View details: {{pledge_url}}
|
||||
Already paid? It can take a few days to appear — you can ignore this.
|
||||
|
||||
No longer wish to donate? {{cancel_url}}`
|
||||
|
||||
const SMS_GENTLE = `{{name}}, your £{{amount}} pledge (ref {{reference}}) is waiting. Pay: {{pay_link}}`
|
||||
|
||||
|
||||
// ─── Step 2: IMPACT NUDGE (Day 7) ───────────────────────────
|
||||
// Connect money to meaning. Lead with purpose, not the ask.
|
||||
// Job: make them FEEL what their money does, then one-tap CTA.
|
||||
|
||||
const WA_IMPACT = `{{name}}, your *£{{amount}}* to *{{event}}* can make a real difference 🤲
|
||||
|
||||
It's been {{days}} days since you pledged. We know life gets busy — this is just a gentle nudge.
|
||||
|
||||
Pay now: {{pay_link}}
|
||||
Ref: \`{{reference}}\`
|
||||
|
||||
Every pound counts. Reply *PAID* if done, or *CANCEL* if plans changed.`
|
||||
|
||||
const EMAIL_IMPACT = `Hi {{name}},
|
||||
|
||||
Your £{{amount}} pledge from {{event}} is still outstanding.
|
||||
Your £{{amount}} pledge to {{event}} is still pending ({{days}} days).
|
||||
|
||||
Every donation makes a real impact. Your contribution helps us continue our vital work.
|
||||
Every donation matters. Your contribution makes a real, tangible difference.
|
||||
|
||||
Complete your pledge: {{pay_link}}
|
||||
|
||||
Payment reference: {{reference}}
|
||||
View details: {{pledge_url}}
|
||||
Reference: {{reference}}
|
||||
|
||||
We know life gets busy. Reply to this email if you need help.
|
||||
|
||||
Need help? Just reply to this email.
|
||||
Cancel pledge: {{cancel_url}}`
|
||||
|
||||
const SMS_IMPACT = `{{name}}, £{{amount}} pledge to {{event}} still pending ({{days}}d). Ref {{reference}}. Pay: {{pay_link}}`
|
||||
|
||||
|
||||
// ─── Step 3: FINAL REMINDER (Day 14) ────────────────────────
|
||||
// Last message. Maximum respect + maximum clarity.
|
||||
// Job: pay or cancel. Both options equally valid. No guilt.
|
||||
|
||||
const WA_FINAL = `Salaam {{name}},
|
||||
|
||||
This is our last message about your *£{{amount}}* pledge to *{{event}}*.
|
||||
|
||||
No pressure — we completely understand if things have changed.
|
||||
|
||||
✅ Pay now: {{pay_link}}
|
||||
❌ Cancel: reply *CANCEL*
|
||||
|
||||
Ref: \`{{reference}}\`
|
||||
|
||||
Whatever you decide, jazākallāhu khayrā for your intention 🤲`
|
||||
|
||||
const EMAIL_FINAL = `Hi {{name}},
|
||||
|
||||
This is our final reminder about your £{{amount}} pledge from {{event}}.
|
||||
This is our final reminder about your £{{amount}} pledge to {{event}}.
|
||||
|
||||
We understand circumstances change. If you'd like to:
|
||||
✅ Pay now — {{pay_link}}
|
||||
❌ Cancel — {{cancel_url}}
|
||||
No pressure — we understand circumstances change.
|
||||
|
||||
✅ Complete your pledge: {{pay_link}}
|
||||
❌ Cancel your pledge: {{cancel_url}}
|
||||
|
||||
Reference: {{reference}}
|
||||
View details: {{pledge_url}}
|
||||
|
||||
Thank you for considering us.
|
||||
Whatever you decide, thank you for your intention.
|
||||
|
||||
{{org_name}}`
|
||||
|
||||
// ─── SMS templates ───────────────────────────────────────────
|
||||
const SMS_FINAL = `Last msg: £{{amount}} pledge to {{event}}, ref {{reference}}. Pay: {{pay_link}} or reply CANCEL. Thank you - {{org_name}}`
|
||||
|
||||
const SMS_RECEIPT = `Thank you, {{name}}! £{{amount}} pledged to {{event}}. Ref: {{reference}}. {{payment_block}}`
|
||||
|
||||
const SMS_DUE_DATE = `Hi {{name}}, your £{{amount}} pledge to {{event}} is due today ({{due_date}}). Ref: {{reference}}. {{payment_block}}`
|
||||
|
||||
const SMS_GENTLE = `Hi {{name}}, reminder: your £{{amount}} pledge to {{event}} ref {{reference}} is pending. Pay: {{pay_link}}`
|
||||
|
||||
const SMS_IMPACT = `{{name}}, your £{{amount}} to {{event}} (ref: {{reference}}) is {{days}} days old. Complete: {{pay_link}} or reply CANCEL.`
|
||||
|
||||
const SMS_FINAL = `Final reminder: £{{amount}} pledge to {{event}}, ref {{reference}}. Pay: {{pay_link}} or reply CANCEL. - {{org_name}}`
|
||||
|
||||
// ─── All defaults ────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_TEMPLATES: TemplateDefaults[] = [
|
||||
// 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: "email", name: "Pledge receipt", subject: "Your £{{amount}} pledge is confirmed — {{event}}", 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: Gentle reminder
|
||||
|
||||
{ step: 4, channel: "whatsapp", name: "Due date", body: WA_DUE_DATE },
|
||||
{ step: 4, channel: "email", name: "Due date", subject: "Today's the day — your £{{amount}} pledge to {{event}}", body: EMAIL_DUE_DATE },
|
||||
{ step: 4, channel: "sms", name: "Due date", body: SMS_DUE_DATE },
|
||||
|
||||
{ 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: £{{amount}} pledge to {{event}}", body: EMAIL_GENTLE },
|
||||
{ step: 1, channel: "sms", name: "Gentle reminder", body: SMS_GENTLE },
|
||||
// Step 2: Impact nudge
|
||||
|
||||
{ step: 2, channel: "whatsapp", name: "Impact nudge", body: WA_IMPACT },
|
||||
{ step: 2, channel: "email", name: "Impact nudge", subject: "Your £{{amount}} pledge is making a difference", body: EMAIL_IMPACT },
|
||||
{ step: 2, channel: "email", name: "Impact nudge", subject: "Your £{{amount}} pledge — every pound counts", body: EMAIL_IMPACT },
|
||||
{ step: 2, channel: "sms", name: "Impact nudge", body: SMS_IMPACT },
|
||||
// Step 3: Final reminder
|
||||
|
||||
{ step: 3, channel: "whatsapp", name: "Final reminder", body: WA_FINAL },
|
||||
{ step: 3, channel: "email", name: "Final reminder", subject: "Final reminder: £{{amount}} pledge", body: EMAIL_FINAL },
|
||||
{ step: 3, channel: "email", name: "Final reminder", subject: "Last reminder: £{{amount}} pledge to {{event}}", body: EMAIL_FINAL },
|
||||
{ step: 3, channel: "sms", name: "Final reminder", body: SMS_FINAL },
|
||||
]
|
||||
|
||||
@@ -400,86 +354,37 @@ export const TEMPLATE_VARIABLES = [
|
||||
{ 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" },
|
||||
// Legacy — still resolved if present
|
||||
{ key: "bank_name", label: "Bank account name (legacy)", example: "Al Furqan Mosque" },
|
||||
{ key: "sort_code", label: "Sort code (legacy)", example: "20-30-80" },
|
||||
{ key: "account_no", label: "Account number (legacy)", example: "12345678" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Resolve template variables with actual values.
|
||||
*/
|
||||
export function resolveTemplate(body: string, vars: Record<string, string>): string {
|
||||
return body.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] || `{{${key}}}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve template with example/preview values.
|
||||
*/
|
||||
export function resolvePreview(body: string): string {
|
||||
const examples: Record<string, string> = {}
|
||||
for (const v of TEMPLATE_VARIABLES) {
|
||||
examples[v.key] = v.example
|
||||
}
|
||||
for (const v of TEMPLATE_VARIABLES) examples[v.key] = v.example
|
||||
return resolveTemplate(body, examples)
|
||||
}
|
||||
|
||||
// ─── Step metadata ───────────────────────────────────────────
|
||||
// DISPLAY ORDER — this is the order shown in the conversation.
|
||||
// Step 4 (due date) sits between receipt and first reminder.
|
||||
// ─── Step metadata (DISPLAY ORDER) ──────────────────────────
|
||||
|
||||
export const STEP_META = [
|
||||
{ step: 0, trigger: "Instantly", label: "Receipt", desc: "Pledge confirmation with payment 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: "🔔" },
|
||||
{ step: 3, trigger: "Day 14", label: "Final reminder", desc: "Last message — pay or cancel", icon: "🔔" },
|
||||
]
|
||||
|
||||
// ─── Strategy presets ────────────────────────────────────────
|
||||
|
||||
export interface StrategyPreset {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
matrix: Record<string, string[]>
|
||||
}
|
||||
export interface StrategyPreset { id: string; name: string; desc: string; matrix: Record<string, string[]> }
|
||||
|
||||
export const STRATEGY_PRESETS: StrategyPreset[] = [
|
||||
{
|
||||
id: "waterfall",
|
||||
name: "Waterfall",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "parallel",
|
||||
name: "Belt & suspenders",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "escalation",
|
||||
name: "Escalation",
|
||||
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"],
|
||||
},
|
||||
},
|
||||
{ id: "waterfall", name: "Waterfall", desc: "WhatsApp → SMS → Email", matrix: { "0": ["whatsapp", "sms", "email"], "4": ["whatsapp", "sms", "email"], "1": ["whatsapp", "sms", "email"], "2": ["whatsapp", "sms", "email"], "3": ["whatsapp", "sms", "email"] } },
|
||||
{ id: "parallel", name: "Belt & suspenders", desc: "All channels for key messages", matrix: { "0": ["whatsapp+email"], "4": ["whatsapp+email"], "1": ["whatsapp"], "2": ["whatsapp+email"], "3": ["whatsapp+email+sms"] } },
|
||||
{ id: "escalation", name: "Escalation", desc: "Add channels as urgency grows", matrix: { "0": ["whatsapp", "email"], "4": ["whatsapp"], "1": ["whatsapp"], "2": ["whatsapp", "email"], "3": ["whatsapp+email+sms"] } },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user