Files
calvana/pledge-now-pay-later/src/app/dashboard/automations/page.tsx
Omair Saleh f1a8c59b0d Automations radical simplification: the page IS the conversation
807 → 394 lines. Removed everything that isn't the answer to
'What do my donors get?'

REMOVED:
- Step timeline tabs (4 across the top)
- Channel tabs (WhatsApp / Email / SMS)
- A/B variant toggle buttons
- AI rewrite toolbar (8 buttons)
- Variable chips panel
- Channel strategy matrix
- Delivery matrix table
- Strategy presets
- Template name editor
- Subject line editor
- Character counter
- Formatting cheatsheet
- Live feed accordion
- Stats bar
- Scheduled reminders list
- Message history feed

WHAT REMAINS:
One WhatsApp conversation showing all 4 messages.
That's the entire page.

- Click a message → it becomes editable inline (green bubble → textarea)
- Hover → ' Try a different approach' appears (AI generates variant B)
- A/B tests show as stacked bubbles with conversion rates
- '🏆 Pick winners' button appears when tests are running
- 'Change timing' link at the bottom (expandable, 3 dropdowns)
- Status line: 'Working · 47 sent · 94% delivered'

The phone mockup is the full-width page content, not a sidebar.
The input bar says 'Donors can reply: PAID · HELP · CANCEL'
Timestamp dividers: 'Instantly', 'Day 2 · if not paid', etc.

This is what Aaisha wants to see: her donors' experience.
2026-03-05 01:24:28 +08:00

395 lines
18 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import {
Loader2, Check, Send, Sparkles, Trophy, CheckCheck,
ChevronDown, Clock, MessageCircle
} from "lucide-react"
import Link from "next/link"
import { resolvePreview, STEP_META } from "@/lib/templates"
/**
* /dashboard/automations
*
* THE PAGE IS THE CONVERSATION.
*
* Aaisha's question: "What do my donors get?"
* Answer: a WhatsApp chat showing all 4 messages, in order,
* with timestamps between them. That's the entire page.
*
* Click a message → it becomes editable (inline).
* Click ✨ → AI generates a smarter version.
* That's it.
*
* No tabs. No matrix. No toolbar. No panels.
* Just a phone with the messages your donors receive.
*/
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 Config {
isActive: boolean; step1Delay: number; step2Delay: number; step3Delay: number
strategy: string; channelMatrix: Record<string, string[]> | null
}
interface ChannelStatus { whatsapp: boolean; email: { provider: string; fromAddress: string } | null; sms: { provider: string; fromNumber: string } | null }
interface Stats { whatsapp: { sent: number; failed: number }; email: { sent: number; failed: number }; sms: { sent: number; failed: number }; total: number; deliveryRate: number }
export default function AutomationsPage() {
const [loading, setLoading] = useState(true)
const [templates, setTemplates] = useState<Template[]>([])
const [config, setConfig] = useState<Config | null>(null)
const [channels, setChannels] = useState<ChannelStatus | null>(null)
const [stats, setStats] = useState<Stats | null>(null)
const [editing, setEditing] = useState<number | null>(null) // step being edited
const [editBody, setEditBody] = useState("")
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState<number | null>(null)
const [aiLoading, setAiLoading] = useState<number | null>(null) // step being AI'd
const [showTiming, setShowTiming] = useState(false)
const editorRef = useRef<HTMLTextAreaElement>(null)
const load = useCallback(async () => {
try {
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)
} catch { /* */ }
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
// Get template for a step (WhatsApp variant A preferred)
const tpl = (step: number, variant = "A") =>
templates.find(t => t.step === step && t.channel === "whatsapp" && t.variant === variant)
|| templates.find(t => t.step === step && t.variant === variant)
const startEdit = (step: number) => {
const t = tpl(step)
if (t) { setEditBody(t.body); setEditing(step) }
setTimeout(() => editorRef.current?.focus(), 50)
}
const cancelEdit = () => { setEditing(null); setEditBody("") }
const saveEdit = async (step: number) => {
setSaving(true)
const t = tpl(step)
try {
await fetch("/api/automations", {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ templates: [{ step, channel: t?.channel || "whatsapp", variant: "A", name: t?.name || "Message", body: editBody }] }),
})
setSaved(step); setEditing(null)
setTimeout(() => setSaved(null), 2000)
await load()
} catch { /* */ }
setSaving(false)
}
const aiGenerate = async (step: number) => {
setAiLoading(step)
try {
const res = await fetch("/api/automations/ai", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate_variant", step, channel: "whatsapp" }),
})
const data = await res.json()
if (data.ok) await load()
} catch { /* */ }
setAiLoading(null)
}
const pickWinners = async () => {
setAiLoading(-1)
try {
const res = await fetch("/api/automations/ai", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "check_winners" }),
})
if (res.ok) await load()
} catch { /* */ }
setAiLoading(null)
}
const removeVariant = async (step: number) => {
try {
await fetch("/api/automations", {
method: "DELETE", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step, channel: "whatsapp", variant: "B" }),
})
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 { /* */ }
}
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
const waConnected = !!channels?.whatsapp
const anyAB = templates.some(t => t.variant === "B")
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
return (
<div className="max-w-lg mx-auto space-y-5">
{/* Header — one line */}
<div>
<h1 className="text-2xl font-black text-[#111827] tracking-tight">What your donors receive</h1>
<p className="text-xs text-gray-500 mt-1">4 messages over {delays[3]} days. Click any to edit.</p>
</div>
{/* Status line */}
{!waConnected ? (
<div className="border-l-2 border-[#F59E0B] bg-[#FEF3C7] px-4 py-3">
<p className="text-xs text-[#111827]"><strong>WhatsApp not connected.</strong> These messages will start sending once you <Link href="/dashboard/settings" className="text-[#1E40AF] font-bold underline">connect WhatsApp</Link>.</p>
</div>
) : stats && stats.total > 0 ? (
<p className="text-xs text-gray-500 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-[#25D366]" /> Working · {stats.total} sent this week · {stats.deliveryRate}% delivered
</p>
) : (
<p className="text-xs text-gray-500 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-[#25D366]" /> Connected · Messages will send as donors pledge
</p>
)}
{/* Pick winners button — only when A/B tests exist */}
{anyAB && (
<button onClick={pickWinners} disabled={aiLoading === -1}
className="w-full border-2 border-[#111827] px-4 py-2.5 text-xs font-bold text-[#111827] hover:bg-[#111827] hover:text-white transition-colors flex items-center justify-center gap-1.5 disabled:opacity-50">
{aiLoading === -1 ? <><Loader2 className="h-3 w-3 animate-spin" /> Checking</> : <><Trophy className="h-3 w-3" /> Pick winners & start new tests</>}
</button>
)}
{/* ── THE CONVERSATION ── */}
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
{/* WhatsApp header */}
<div className="bg-[#075E54] px-4 py-3 flex items-center gap-3">
<span className="text-white/50 text-sm"></span>
<div className="w-9 h-9 bg-[#128C7E] flex items-center justify-center" style={{ borderRadius: "50%" }}>
<MessageCircle className="h-4 w-4 text-white" />
</div>
<div>
<p className="text-white text-sm font-medium">Your charity</p>
<p className="text-[10px] text-white/50">Automated messages</p>
</div>
</div>
{/* Chat area */}
<div className="bg-[#ECE5DD] px-4 py-4 space-y-4" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}>
{STEP_META.map((meta, step) => {
const a = tpl(step, "A")
const b = tpl(step, "B")
const isEditing = editing === step
const justSaved = saved === step
const isAiLoading = aiLoading === step
const delay = delays[step]
const previewA = a ? resolvePreview(a.body) : ""
const previewB = b ? resolvePreview(b.body) : ""
return (
<div key={step}>
{/* Timestamp divider */}
<div className="flex justify-center mb-3">
<span className="bg-white/80 text-[10px] text-[#667781] px-3 py-1 font-medium shadow-sm" style={{ borderRadius: "6px" }}>
{step === 0 ? "Instantly" : `Day ${delay} · if not paid`}
</span>
</div>
{/* A/B split — two bubbles stacked */}
{b && !isEditing ? (
<div className="space-y-2">
<ABBubble
label="A" body={previewA} pct={a?.splitPercent || 50}
sent={a?.sentCount || 0} converted={a?.convertedCount || 0}
onClick={() => startEdit(step)}
/>
<ABBubble
label="B" body={previewB} pct={b.splitPercent}
sent={b.sentCount} converted={b.convertedCount}
onClick={() => startEdit(step)} isAI
/>
<button onClick={() => removeVariant(step)} className="text-[9px] text-[#667781] hover:text-[#DC2626] ml-2 transition-colors">
End test
</button>
</div>
) : isEditing ? (
/* ── Editing mode ── */
<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"
/>
<div className="px-3 pb-2 flex items-center gap-2 flex-wrap">
<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={cancelEdit} className="text-[10px] text-[#667781] hover:text-[#303030]">Cancel</button>
<span className="text-[9px] text-[#667781]/50 ml-auto">
Use {"{{name}}"} {"{{amount}}"} {"{{reference}}"}
</span>
</div>
</div>
</div>
) : (
/* ── Normal bubble ── */
<div className="flex justify-end group">
<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"
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%" }}>
<Check className="h-3 w-3 text-white" />
</div>
)}
<WhatsAppFormatted text={previewA} />
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
<span className="text-[9px] text-[#667781]">
{step === 0 ? "09:41" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"}
</span>
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
</div>
</button>
</div>
)}
{/* AI button — below each message, subtle */}
{!isEditing && !b && (
<div className="flex justify-end mt-1.5">
<button onClick={() => aiGenerate(step)} disabled={isAiLoading}
className="text-[10px] text-[#667781] hover:text-[#075E54] transition-colors flex items-center gap-1 opacity-0 group-hover:opacity-100 focus:opacity-100"
style={{ opacity: isAiLoading ? 1 : undefined }}>
{isAiLoading ? <><Loader2 className="h-2.5 w-2.5 animate-spin" /> AI is writing</> : <><Sparkles className="h-2.5 w-2.5" /> Try a different approach</>}
</button>
</div>
)}
</div>
)
})}
</div>
{/* Input bar */}
<div className="bg-[#F0F0F0] px-3 py-2 flex items-center gap-2">
<div className="flex-1 bg-white px-4 py-2 text-[11px] text-[#667781]" style={{ borderRadius: "20px" }}>
Donors can reply: PAID · HELP · CANCEL
</div>
<div className="w-9 h-9 bg-[#075E54] flex items-center justify-center shrink-0" style={{ borderRadius: "50%" }}>
<Send className="h-4 w-4 text-white ml-0.5" />
</div>
</div>
</div>
{/* ── Timing (expandable) ── */}
<button onClick={() => setShowTiming(!showTiming)} className="w-full text-left flex items-center gap-2 text-xs text-gray-400 hover:text-gray-600 transition-colors py-1">
<Clock className="h-3 w-3" /> Change timing
<ChevronDown className={`h-3 w-3 transition-transform ${showTiming ? "rotate-180" : ""}`} />
</button>
{showTiming && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: "First reminder", key: "step1Delay", value: config?.step1Delay || 2 },
{ label: "Second reminder", key: "step2Delay", value: config?.step2Delay || 7 },
{ label: "Final reminder", key: "step3Delay", value: config?.step3Delay || 14 },
].map(t => (
<div key={t.key}>
<p className="text-[9px] font-bold text-gray-400 uppercase tracking-wide mb-1">{t.label}</p>
<select value={t.value} onChange={e => saveTiming(t.key, parseInt(e.target.value))}
className="w-full border-2 border-gray-200 px-2 py-1.5 text-xs font-bold text-[#111827] bg-white focus:border-[#1E40AF] outline-none">
{[1, 2, 3, 5, 7, 10, 14, 21, 28].map(d => <option key={d} value={d}>Day {d}</option>)}
</select>
</div>
))}
</div>
)}
</div>
)
}
// ─── A/B Bubble ─────────────────────────────────────────────
function ABBubble({ label, body, pct, sent, converted, onClick, isAI }: {
label: string; body: string; pct: number
sent: number; converted: number; onClick: () => void; isAI?: boolean
}) {
const rate = sent > 0 ? Math.round((converted / sent) * 100) : 0
return (
<div className="flex justify-end">
<button onClick={onClick}
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"
style={{ borderRadius: "8px 0 8px 8px" }}>
{/* Label bar */}
<div className="flex items-center gap-1.5 mb-1.5 -mt-0.5">
<span className="text-[8px] font-bold bg-[#075E54]/10 text-[#075E54] px-1.5 py-0.5">
{label} · {pct}%
</span>
{isAI && <Sparkles className="h-2.5 w-2.5 text-[#075E54]/40" />}
{sent > 0 && (
<span className="text-[8px] text-[#667781] ml-auto">
{rate}% paid {rate > 0 && converted > 3 && "🏆"}
</span>
)}
</div>
<WhatsAppFormatted text={body} />
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
<span className="text-[9px] text-[#667781]">{sent} sent</span>
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
</div>
</button>
</div>
)
}
// ─── WhatsApp Formatted Text ────────────────────────────────
function WhatsAppFormatted({ text }: { text: string }) {
return (
<>
{text.split("\n").map((line, i) => {
if (/^[━─═]{3,}$/.test(line.trim())) return <div key={i} className="border-t border-[#25D366]/20 my-1" />
const parts = line.split(/(\*[^*]+\*|_[^_]+_|`[^`]+`)/)
return (
<div key={i} className={line.trim() === "" ? "h-2" : ""}>
{parts.map((p, j) => {
if (p.startsWith("*") && p.endsWith("*")) return <strong key={j}>{p.slice(1, -1)}</strong>
if (p.startsWith("_") && p.endsWith("_")) return <em key={j}>{p.slice(1, -1)}</em>
if (p.startsWith("`") && p.endsWith("`")) return <code key={j} className="bg-black/5 px-1 py-0.5 text-[10px] font-mono" style={{ borderRadius: "3px" }}>{p.slice(1, -1)}</code>
return <span key={j}>{p}</span>
})}
</div>
)
})}
</>
)
}