From 17b3e15faebcd7f73555f29b9043a85f8eb88ccd Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Thu, 5 Mar 2026 00:22:18 +0800 Subject: [PATCH] Automations deep redesign: message design studio with WhatsApp-native preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COMPLETE RETHINK — from monitoring dashboard to message design studio. ## The Big Idea Aaisha doesn't need a dashboard that says 'is it working?' She needs a studio where she can SEE what Ahmed sees on his phone, EDIT the words, TEST different approaches, and DESIGN cross-channel sequences. The WhatsApp phone mockup is the star. ## New: Phone Mockups (3 channels) - WhatsApp: green bubbles, blue ticks, org avatar, chat wallpaper, full formatting (*bold*, _italic_, `code`, ━━━ dividers) - Email: macOS mail client chrome, From header, subject line - SMS: iOS Messages style, grey bubbles, contact avatar ## New: Template Editor - Editable templates per step (receipt, day 2, 7, 14) per channel - Live preview in phone mockup as you type - Variable insertion chips: {{name}}, {{amount}}, {{reference}}, etc. - Subject line editor for email channel - Character count + SMS segment counter ## New: A/B Testing - Create Variant B of any step/channel message - 50/50 split traffic automatically - Track sent count + conversion rate (paid after receiving) - Side-by-side stats: 'A: 33% paid, B: 54% paid ★' - Delete variant to revert to single message ## New: Channel Strategy Matrix - 3 presets: Waterfall (default), Belt & Suspenders, Escalation - Visual matrix: steps × channels with status indicators - 1st = primary, fb = fallback, + = parallel send - Waterfall: WhatsApp → SMS → Email (most cost-effective) - Belt & Suspenders: all channels for receipts + final - Escalation: start gentle (WA only), add channels as urgency increases ## New: Customizable Timing - Each step's delay is editable inline (dropdown next to phone) - Default: Day 2, Day 7, Day 14 - Can change to any schedule: Day 1, Day 3, Day 21, Day 28 ## Schema: 2 new models - MessageTemplate: per-org editable templates with A/B variants (step, channel, variant, body, subject, splitPercent, sentCount, convertedCount) - AutomationConfig: per-org timing + strategy + channel matrix ## API: /api/automations (GET/PATCH/DELETE) - GET seeds defaults on first load (12 templates: 4 steps × 3 channels) - PATCH upserts templates and config - DELETE removes variant B and resets A to 100% ## Default templates (src/lib/templates.ts) Extracted from hardcoded whatsapp.ts + reminders.ts into editable templates: - WhatsApp: receipt, gentle, impact, final (with emoji + formatting) - Email: receipt, gentle, impact, final (with cancel/pledge URLs) - SMS: receipt, gentle, impact, final (160-char optimized) ## Architecture templates.ts → resolvePreview() fills {{variables}} with examples templates.ts → resolveTemplate() fills {{variables}} with real data messaging.ts → sendToDonor() routes via channel waterfall automations/route.ts → seeds + CRUD for templates + config ## Visual: Step timeline at top 4 tabs across the top with emoji, timing, description Active step is dark (111827), others are white Click to switch — editor and phone update together ## Layout [Step Timeline — 4 tabs across top] [Phone Mockup (left) | Editor (right)] [Channel Strategy — expandable matrix] [Live Feed — condensed stats + scheduled + messages] --- pledge-now-pay-later/prisma/schema.prisma | 45 +- .../src/app/api/automations/route.ts | 198 +++ .../src/app/dashboard/automations/page.tsx | 1089 ++++++++++++----- pledge-now-pay-later/src/lib/templates.ts | 256 ++++ 4 files changed, 1262 insertions(+), 326 deletions(-) create mode 100644 pledge-now-pay-later/src/app/api/automations/route.ts create mode 100644 pledge-now-pay-later/src/lib/templates.ts diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma index c7296f1..d983fd8 100644 --- a/pledge-now-pay-later/prisma/schema.prisma +++ b/pledge-now-pay-later/prisma/schema.prisma @@ -37,10 +37,12 @@ model Organization { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - users User[] - events Event[] - pledges Pledge[] - imports Import[] + users User[] + events Event[] + pledges Pledge[] + imports Import[] + messageTemplates MessageTemplate[] + automationConfig AutomationConfig? @@index([slug]) } @@ -234,6 +236,41 @@ model Import { @@index([organizationId]) } +model MessageTemplate { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + step Int // 0=receipt, 1=day2_gentle, 2=day7_impact, 3=day14_final + channel String // whatsapp, email, sms + variant String @default("A") // A, B for A/B testing + name String // human label: "Gentle reminder", "Impact nudge" + subject String? // email-only: subject line + body String // template with {{variables}} + isActive Boolean @default(true) + splitPercent Int @default(100) // A/B: e.g. 50 = 50% get this variant + sentCount Int @default(0) + convertedCount Int @default(0) // donors who paid after receiving this + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, step, channel, variant]) + @@index([organizationId]) +} + +model AutomationConfig { + id String @id @default(cuid()) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + isActive Boolean @default(true) + step1Delay Int @default(2) // days after pledge for step 1 + step2Delay Int @default(7) // days after pledge for step 2 + step3Delay Int @default(14) // days after pledge for step 3 + strategy String @default("waterfall") // waterfall, parallel, escalation, custom + channelMatrix Json? // per-step channel config: { "0": ["whatsapp","email"], "1": ["whatsapp"], ... } + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model AnalyticsEvent { id String @id @default(cuid()) eventType String // pledge_start, amount_selected, rail_selected, identity_submitted, pledge_completed, instruction_copy_clicked, i_paid_clicked, payment_matched diff --git a/pledge-now-pay-later/src/app/api/automations/route.ts b/pledge-now-pay-later/src/app/api/automations/route.ts new file mode 100644 index 0000000..ad9bd5d --- /dev/null +++ b/pledge-now-pay-later/src/app/api/automations/route.ts @@ -0,0 +1,198 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { getUser } from "@/lib/session" +import { DEFAULT_TEMPLATES, STRATEGY_PRESETS } from "@/lib/templates" +import { getOrgChannels, getChannelStats, getMessageHistory, getPendingReminders } from "@/lib/messaging" + +/** + * GET /api/automations + * + * Returns EVERYTHING the Automations page needs: + * - templates (per step/channel/variant) + * - config (timing, strategy, channel matrix) + * - channels (which are live) + * - stats (delivery numbers) + * - history (recent messages) + * - pending (upcoming reminders) + * + * Seeds default templates on first load. + */ +export async function GET() { + const user = await getUser() + if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 }) + + const orgId = user.orgId + + // Seed defaults if no templates exist + const templateCount = await prisma.messageTemplate.count({ where: { organizationId: orgId } }) + if (templateCount === 0) { + await prisma.messageTemplate.createMany({ + data: DEFAULT_TEMPLATES.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 + let config = await prisma.automationConfig.findUnique({ where: { organizationId: orgId } }) + if (!config) { + config = await prisma.automationConfig.create({ + data: { + organizationId: orgId, + strategy: "waterfall", + channelMatrix: STRATEGY_PRESETS[0].matrix, + }, + }) + } + + // Load everything in parallel + const [templates, channels, stats, history, pending] = await Promise.all([ + prisma.messageTemplate.findMany({ + where: { organizationId: orgId }, + orderBy: [{ step: "asc" }, { channel: "asc" }, { variant: "asc" }], + }), + getOrgChannels(orgId), + getChannelStats(orgId, 7), + getMessageHistory(orgId, 30), + getPendingReminders(orgId, 10), + ]) + + return NextResponse.json({ + templates, + config: { + isActive: config.isActive, + step1Delay: config.step1Delay, + step2Delay: config.step2Delay, + step3Delay: config.step3Delay, + strategy: config.strategy, + channelMatrix: config.channelMatrix, + }, + channels, + stats, + history, + pending, + }) +} + +/** + * PATCH /api/automations + * + * Update templates and/or config. + * Body: { templates?: [...], config?: {...} } + */ +export async function PATCH(request: NextRequest) { + const user = await getUser() + if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 }) + + const orgId = user.orgId + const body = await request.json() + + // Update templates + if (body.templates && Array.isArray(body.templates)) { + for (const t of body.templates) { + if (!t.step && t.step !== 0) continue + if (!t.channel) continue + + await prisma.messageTemplate.upsert({ + where: { + organizationId_step_channel_variant: { + organizationId: orgId, + step: t.step, + channel: t.channel, + variant: t.variant || "A", + }, + }, + update: { + name: t.name, + subject: t.subject || null, + body: t.body, + isActive: t.isActive ?? true, + splitPercent: t.splitPercent ?? 100, + }, + create: { + organizationId: orgId, + step: t.step, + channel: t.channel, + variant: t.variant || "A", + name: t.name || "Custom", + subject: t.subject || null, + body: t.body, + isActive: t.isActive ?? true, + splitPercent: t.splitPercent ?? 100, + }, + }) + } + } + + // Update config + if (body.config) { + const c = body.config + await prisma.automationConfig.upsert({ + where: { organizationId: orgId }, + update: { + isActive: c.isActive ?? undefined, + step1Delay: c.step1Delay ?? undefined, + step2Delay: c.step2Delay ?? undefined, + step3Delay: c.step3Delay ?? undefined, + strategy: c.strategy ?? undefined, + channelMatrix: c.channelMatrix ?? undefined, + }, + create: { + organizationId: orgId, + isActive: c.isActive ?? true, + step1Delay: c.step1Delay ?? 2, + step2Delay: c.step2Delay ?? 7, + step3Delay: c.step3Delay ?? 14, + strategy: c.strategy ?? "waterfall", + channelMatrix: c.channelMatrix ?? null, + }, + }) + } + + return NextResponse.json({ ok: true }) +} + +/** + * DELETE /api/automations + * + * Delete a variant B template (revert to A-only). + * Body: { step, channel, variant } + */ +export async function DELETE(request: NextRequest) { + const user = await getUser() + if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 }) + + const body = await request.json() + if (body.variant === "A") return NextResponse.json({ error: "Cannot delete variant A" }, { status: 400 }) + + await prisma.messageTemplate.deleteMany({ + where: { + organizationId: user.orgId, + step: body.step, + channel: body.channel, + variant: body.variant, + }, + }) + + // Reset variant A to 100% + await prisma.messageTemplate.updateMany({ + where: { + organizationId: user.orgId, + step: body.step, + channel: body.channel, + variant: "A", + }, + data: { splitPercent: 100 }, + }) + + return NextResponse.json({ ok: true }) +} diff --git a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx index e510424..1af287d 100644 --- a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx @@ -1,120 +1,96 @@ "use client" -import { useState, useEffect, useCallback } from "react" -import { formatPence } from "@/lib/utils" +import { useState, useEffect, useCallback, useRef } from "react" import { Loader2, MessageCircle, Mail, Smartphone, Check, X, Clock, - ChevronDown, Send, Zap, AlertTriangle, Radio + ChevronDown, Send, Zap, AlertTriangle, Plus, Trash2, + Pencil, RotateCcw, CheckCheck } from "lucide-react" import Link from "next/link" +import { resolvePreview, TEMPLATE_VARIABLES, STEP_META, STRATEGY_PRESETS } from "@/lib/templates" /** - * /dashboard/automations — "Is it working? What's being sent?" + * /dashboard/automations — THE MESSAGE DESIGN STUDIO * - * THIS IS THE STAR OF THE SHOW. + * This is not a monitoring dashboard. This is where Aaisha designs + * the messages her donors receive. She sees exactly what Ahmed + * sees on his phone — a WhatsApp-native mockup with green bubbles, + * blue ticks, and her charity's name in the header. * - * The entire product is an automation engine. Without it, PNPL is - * just a spreadsheet. This page makes the engine VISIBLE. - * - * Aaisha's questions: - * 1. "Are messages actually being sent?" → Live channel status - * 2. "What did Ahmed receive?" → Message feed with previews - * 3. "What happens after someone pledges?" → Visual pipeline - * 4. "Is everything working?" → Delivery stats - * 5. "What's coming up next?" → Scheduled reminders - * - * The page has 4 sections: - * A. Hero stats bar (dark) — messages this week, delivery rate - * B. Live channels — which pipes are active - * C. The Pipeline — visual "what happens after a pledge" - * D. Message feed — recent messages with status + * Layout: + * ┌─────────────────────────────────────────────────────────────┐ + * │ STEP TIMELINE (4 tabs across the top) │ + * ├─────────────────────┬───────────────────────────────────────┤ + * │ PHONE MOCKUP │ EDITOR │ + * │ (WhatsApp-native) │ Channel tabs + textarea + variables │ + * │ │ A/B variant toggle │ + * │ │ Save / Test / Reset │ + * ├─────────────────────┴───────────────────────────────────────┤ + * │ CHANNEL STRATEGY (matrix + presets) │ + * ├─────────────────────────────────────────────────────────────┤ + * │ STATS + FEED (condensed at bottom) │ + * └─────────────────────────────────────────────────────────────┘ */ -interface ChannelStatus { - whatsapp: boolean - email: { provider: string; fromAddress: string } | null - sms: { provider: string; fromNumber: string } | null +// ─── Types ─────────────────────────────────────────────────── + +interface Template { + id: string; step: number; channel: string; variant: string + name: string; subject: string | null; body: string + isActive: boolean; splitPercent: number + sentCount: number; convertedCount: number } -interface ChannelStats { - whatsapp: { sent: number; failed: number } - email: { sent: number; failed: number } - sms: { sent: number; failed: number } - total: number - deliveryRate: number +interface Config { + isActive: boolean; step1Delay: number; step2Delay: number; step3Delay: number + strategy: string; channelMatrix: Record | null } -interface MessageEntry { - id: string; channel: string; messageType: string - donorName: string | null; success: boolean - error: string | null; createdAt: string +interface ChannelStatus { whatsapp: boolean; email: { provider: string; fromAddress: string } | null; sms: { provider: string; fromNumber: string } | null } +interface ChannelStats { whatsapp: { sent: number; failed: number }; email: { sent: number; failed: number }; sms: { sent: number; failed: number }; total: number; deliveryRate: number } +interface MessageEntry { id: string; channel: string; messageType: string; donorName: string | null; success: boolean; error: string | null; createdAt: string } +interface PendingEntry { id: string; donorName: string | null; amountPence: number; step: number; channel: string; scheduledAt: string } + +const CHANNELS = ["whatsapp", "email", "sms"] as const +type Channel = typeof CHANNELS[number] +const CH_META: Record = { + whatsapp: { icon: MessageCircle, color: "#25D366", label: "WhatsApp" }, + email: { icon: Mail, color: "#1E40AF", label: "Email" }, + sms: { icon: Smartphone, color: "#F59E0B", label: "SMS" }, } -interface PendingReminder { - id: string; donorName: string | null; amountPence: number - step: number; channel: string; scheduledAt: string -} - -const CHANNEL_ICONS: Record = { - whatsapp: MessageCircle, email: Mail, sms: Smartphone, -} -const CHANNEL_COLORS: Record = { - whatsapp: "#25D366", email: "#1E40AF", sms: "#F59E0B", -} -const MSG_LABELS: Record = { - receipt: "Receipt", - reminder_1: "Reminder 1", - reminder_2: "Reminder 2", - reminder_3: "Reminder 3", - reminder_4: "Final reminder", - overdue_notice: "Overdue notice", - payment_confirmed: "Payment confirmed", - test: "Test message", -} - -const PIPELINE_STEPS = [ - { - trigger: "Someone pledges", - title: "Receipt", - desc: "Bank details, reference, confirmation", - timing: "Instantly", - messageType: "receipt", - }, - { - trigger: "Day 2", - title: "Gentle reminder", - desc: "\"Just a quick reminder about your pledge...\"", - timing: "If not paid", - messageType: "reminder_1", - }, - { - trigger: "Day 7", - title: "Impact nudge", - desc: "\"Your £X helps fund...\" — why it matters", - timing: "If not paid", - messageType: "reminder_2", - }, - { - trigger: "Day 14", - title: "Final reminder", - desc: "\"This is our final message\" — reply PAID/CANCEL", - timing: "Marks overdue if no response", - messageType: "reminder_3", - }, -] +// ─── Page ──────────────────────────────────────────────────── export default function AutomationsPage() { const [loading, setLoading] = useState(true) + const [templates, setTemplates] = useState([]) + const [config, setConfig] = useState(null) const [channels, setChannels] = useState(null) const [stats, setStats] = useState(null) const [history, setHistory] = useState([]) - const [pending, setPending] = useState([]) - const [pipelineOpen, setPipelineOpen] = useState(false) + const [pending, setPending] = useState([]) + + // Current editor state + const [activeStep, setActiveStep] = useState(0) + const [activeChannel, setActiveChannel] = useState("whatsapp") + const [activeVariant, setActiveVariant] = useState("A") + const [editBody, setEditBody] = useState("") + const [editSubject, setEditSubject] = useState("") + const [editName, setEditName] = useState("") + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [showStrategy, setShowStrategy] = useState(false) + const [showFeed, setShowFeed] = useState(false) + + const editorRef = useRef(null) const load = useCallback(async () => { try { - const res = await fetch("/api/messaging/status") + const res = await fetch("/api/automations") const data = await res.json() + if (data.templates) setTemplates(data.templates) + if (data.config) setConfig(data.config) if (data.channels) setChannels(data.channels) if (data.stats) setStats(data.stats) if (data.history) setHistory(data.history) @@ -125,13 +101,135 @@ export default function AutomationsPage() { useEffect(() => { load() }, [load]) - if (loading) return
+ // Sync editor when selection changes + useEffect(() => { + const tpl = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant) + if (tpl) { + setEditBody(tpl.body) + setEditSubject(tpl.subject || "") + setEditName(tpl.name) + setDirty(false) + } else { + setEditBody("") + setEditSubject("") + setEditName(STEP_META[activeStep]?.label || "") + setDirty(false) + } + }, [activeStep, activeChannel, activeVariant, templates]) + + const currentTemplate = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant) + const variantB = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "B") + const hasVariantB = !!variantB const activeChannels = [ - channels?.whatsapp ? "WhatsApp" : null, - channels?.email ? "Email" : null, - channels?.sms ? "SMS" : null, - ].filter(Boolean) + channels?.whatsapp ? "whatsapp" : null, + channels?.email ? "email" : null, + channels?.sms ? "sms" : null, + ].filter(Boolean) as Channel[] + + // ─── Actions ────────────────────────────── + + const saveTemplate = async () => { + setSaving(true) + try { + await fetch("/api/automations", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templates: [{ + step: activeStep, + channel: activeChannel, + variant: activeVariant, + name: editName, + subject: activeChannel === "email" ? editSubject : null, + body: editBody, + splitPercent: hasVariantB ? 50 : 100, + }], + }), + }) + setSaved(true) + setDirty(false) + setTimeout(() => setSaved(false), 2000) + await load() + } catch { /* */ } + setSaving(false) + } + + const createVariantB = async () => { + const tplA = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "A") + if (!tplA) return + setSaving(true) + try { + await fetch("/api/automations", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templates: [ + { step: activeStep, channel: activeChannel, variant: "A", name: tplA.name, subject: tplA.subject, body: tplA.body, splitPercent: 50 }, + { step: activeStep, channel: activeChannel, variant: "B", name: tplA.name + " (B)", subject: tplA.subject, body: tplA.body, splitPercent: 50 }, + ], + }), + }) + setActiveVariant("B") + await load() + } catch { /* */ } + setSaving(false) + } + + const deleteVariantB = async () => { + setSaving(true) + try { + await fetch("/api/automations", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: activeStep, channel: activeChannel, variant: "B" }), + }) + setActiveVariant("A") + await load() + } catch { /* */ } + setSaving(false) + } + + const saveStrategy = async (strategy: string, matrix: Record) => { + try { + await fetch("/api/automations", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config: { strategy, channelMatrix: matrix } }), + }) + await load() + } catch { /* */ } + } + + const saveTiming = async (key: string, value: number) => { + try { + await fetch("/api/automations", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config: { [key]: value } }), + }) + await load() + } catch { /* */ } + } + + const insertVariable = (key: string) => { + const el = editorRef.current + if (!el) return + const start = el.selectionStart + const end = el.selectionEnd + const insert = `{{${key}}}` + const newBody = editBody.slice(0, start) + insert + editBody.slice(end) + setEditBody(newBody) + setDirty(true) + setTimeout(() => { + el.focus() + el.setSelectionRange(start + insert.length, start + insert.length) + }, 0) + } + + // ─── Render ─────────────────────────────── + + if (loading) return
const noChannels = activeChannels.length === 0 @@ -139,226 +237,444 @@ export default function AutomationsPage() {
{/* ── Header ── */} -
-

Pledge follow-up engine

-

Automations

-
- - {/* ── A. Hero stats (dark) ── */} -
-

Last 7 days

-
- {[ - { value: String(stats?.whatsapp.sent || 0), label: "WhatsApp", color: "text-[#25D366]" }, - { value: String(stats?.email.sent || 0), label: "Email", color: "text-[#60A5FA]" }, - { value: String(stats?.sms.sent || 0), label: "SMS", color: "text-[#FBBF24]" }, - { value: String(stats?.total || 0), label: "Total messages", color: "text-white" }, - { value: `${stats?.deliveryRate || 0}%`, label: "Delivered", color: (stats?.deliveryRate || 0) >= 90 ? "text-[#4ADE80]" : "text-[#FBBF24]" }, - ].map(s => ( -
-

{s.value}

-

{s.label}

-
- ))} +
+
+

Message design studio

+

Automations

-
- - {/* ── B. Live channels ── */} -
-
-

Channels

- - Configure in Settings → - -
- - {noChannels ? ( -
- -

No channels connected

-

Connect WhatsApp, Email, or SMS in Settings to start sending automatic receipts and reminders.

- - Go to Settings - -
- ) : ( -
- {/* WhatsApp */} - - {/* Email */} - - {/* SMS */} - + {stats && stats.total > 0 && ( +
+

{stats.total}

+

messages this week · {stats.deliveryRate}% delivered

)}
- {/* ── C. The Pipeline — what happens after a pledge ── */} + {/* ── No channels warning ── */} + {noChannels && ( +
+ +
+

No channels connected yet

+

Connect WhatsApp in Settings to start sending messages. You can design your messages now and they'll start sending once connected.

+
+
+ )} + + {/* ── Step Timeline ── */} +
+ {STEP_META.map((s, i) => { + const isActive = activeStep === i + const delay = i === 0 ? "Instant" : i === 1 ? `Day ${config?.step1Delay || 2}` : i === 2 ? `Day ${config?.step2Delay || 7}` : `Day ${config?.step3Delay || 14}` + const tplCount = templates.filter(t => t.step === i).length + return ( + + ) + })} +
+ + {/* ── Main: Phone + Editor ── */} +
+ + {/* ── LEFT: Phone Mockup ── */} +
+ + + {/* Step timing (editable) */} + {activeStep > 0 && ( +
+ + Send + + after pledge (if not paid) +
+ )} +
+ + {/* ── RIGHT: Editor ── */} +
+ + {/* Channel tabs */} +
+ {CHANNELS.map(ch => { + const m = CH_META[ch] + const isActive = activeChannel === ch + const isLive = activeChannels.includes(ch) + const Icon = m.icon + return ( + + ) + })} +
+ + {/* A/B variant toggle */} +
+ + {hasVariantB ? ( + <> + + + {/* A/B stats */} + {(currentTemplate?.sentCount || 0) + (variantB?.sentCount || 0) > 0 && ( +
+ + A: {currentTemplate?.sentCount || 0} sent → {currentTemplate?.convertedCount || 0} paid + ({currentTemplate?.sentCount ? Math.round(((currentTemplate?.convertedCount || 0) / currentTemplate.sentCount) * 100) : 0}%) + + + B: {variantB.sentCount} sent → {variantB.convertedCount} paid + ({variantB.sentCount ? Math.round((variantB.convertedCount / variantB.sentCount) * 100) : 0}%) + +
+ )} + + ) : ( + + )} +
+ + {/* Template name */} +
+ { setEditName(e.target.value); setDirty(true) }} + className="text-lg font-black text-[#111827] bg-transparent border-b-2 border-transparent hover:border-gray-200 focus:border-[#1E40AF] outline-none w-full pb-1 transition-colors" + placeholder="Template name..." + /> +
+ + {/* Email subject */} + {activeChannel === "email" && ( +
+ + { setEditSubject(e.target.value); setDirty(true) }} + className="w-full h-10 px-3 border-2 border-gray-200 text-sm font-medium placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" + placeholder="e.g. Your £{{amount}} pledge — payment details" + /> +

Preview: {resolvePreview(editSubject || "")}

+
+ )} + + {/* Message body editor */} +
+
+ + {editBody.length} chars{activeChannel === "sms" ? ` · ${Math.ceil(editBody.length / 160)} SMS` : ""} +
+