Automations deep redesign: message design studio with WhatsApp-native preview
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]
This commit is contained in:
198
pledge-now-pay-later/src/app/api/automations/route.ts
Normal file
198
pledge-now-pay-later/src/app/api/automations/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user