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.
395 lines
18 KiB
TypeScript
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>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|