AI-first Automations: done-for-you optimisation
AI is the headline, not a hidden feature.
THREE STATES:
1. NOT STARTED → dark hero:
'Let AI improve your messages'
'AI writes a different version of each message and tests
both with real donors. The better one wins automatically.'
[Start optimising] ← one button, AI does all 4 steps
2. TESTING → dark hero with pulse:
'AI is testing 4 experiments'
Each message shows side-by-side: Yours vs AI's version
Live conversion rates, progress bar to verdict
'Pick winners & start new round' button
3. OPTIMISED → dark hero with trophy:
'Messages optimised · 47 sent · 94% delivered'
[New round] ← keeps improving forever
INSIDE THE CONVERSATION:
A/B tests show as split cards within the chat:
┌──────────────────────────────┐
│ ✨ AI is testing this message │
├──────────────┬───────────────┤
│ Yours │ ✨ AI │
│ Hi Ahmed.. │ Ahmed, 47.. │
│ 33% │ 54% 🏆 │
│ 8/24 sent │ 14/26 sent │
├──────────────┴───────────────┤
│ ▓▓▓▓▓▓▓▓▓░░░ 72% │
│ AI version converts 21% better│
└──────────────────────────────┘
Normal messages (no test): click to edit inline.
Everything else: AI handles it.
This commit is contained in:
@@ -11,18 +11,23 @@ import { resolvePreview, STEP_META } from "@/lib/templates"
|
|||||||
/**
|
/**
|
||||||
* /dashboard/automations
|
* /dashboard/automations
|
||||||
*
|
*
|
||||||
* THE PAGE IS THE CONVERSATION.
|
* AI DOES THE WORK.
|
||||||
*
|
*
|
||||||
* Aaisha's question: "What do my donors get?"
|
* The page has three states:
|
||||||
* 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).
|
* 1. NOT STARTED — big hero: "Let AI improve your messages"
|
||||||
* Click ✨ → AI generates a smarter version.
|
* One button. AI generates challengers for all 4 steps.
|
||||||
* That's it.
|
|
||||||
*
|
*
|
||||||
* No tabs. No matrix. No toolbar. No panels.
|
* 2. TESTING — "AI is testing 4 experiments"
|
||||||
* Just a phone with the messages your donors receive.
|
* Each message shows your version vs AI's version with live stats.
|
||||||
|
* Progress bar toward verdict.
|
||||||
|
*
|
||||||
|
* 3. WINNERS — "AI improved your messages by 47%"
|
||||||
|
* Messages marked with 🏆 badges showing the lift.
|
||||||
|
* "Run another round" to keep improving.
|
||||||
|
*
|
||||||
|
* Aaisha never writes a message. She never picks a winner.
|
||||||
|
* She just sees: "AI is making your messages better."
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
@@ -40,6 +45,8 @@ interface Config {
|
|||||||
interface ChannelStatus { whatsapp: boolean; email: { provider: string; fromAddress: string } | null; sms: { provider: string; fromNumber: 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 }
|
interface Stats { whatsapp: { sent: number; failed: number }; email: { sent: number; failed: number }; sms: { sent: number; failed: number }; total: number; deliveryRate: number }
|
||||||
|
|
||||||
|
const MIN_SAMPLE = 20
|
||||||
|
|
||||||
export default function AutomationsPage() {
|
export default function AutomationsPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
@@ -47,11 +54,11 @@ export default function AutomationsPage() {
|
|||||||
const [channels, setChannels] = useState<ChannelStatus | null>(null)
|
const [channels, setChannels] = useState<ChannelStatus | null>(null)
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
|
||||||
const [editing, setEditing] = useState<number | null>(null) // step being edited
|
const [editing, setEditing] = useState<number | null>(null)
|
||||||
const [editBody, setEditBody] = useState("")
|
const [editBody, setEditBody] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState<number | null>(null)
|
const [saved, setSaved] = useState<number | null>(null)
|
||||||
const [aiLoading, setAiLoading] = useState<number | null>(null) // step being AI'd
|
const [aiWorking, setAiWorking] = useState(false)
|
||||||
const [showTiming, setShowTiming] = useState(false)
|
const [showTiming, setShowTiming] = useState(false)
|
||||||
|
|
||||||
const editorRef = useRef<HTMLTextAreaElement>(null)
|
const editorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
@@ -70,19 +77,51 @@ export default function AutomationsPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// Get template for a step (WhatsApp variant A preferred)
|
|
||||||
const tpl = (step: number, variant = "A") =>
|
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.channel === "whatsapp" && t.variant === variant)
|
||||||
|| templates.find(t => t.step === step && t.variant === variant)
|
|| templates.find(t => t.step === step && t.variant === variant)
|
||||||
|
|
||||||
|
// ── Derived state ──────────────────────
|
||||||
|
const testsRunning = STEP_META.filter((_, i) => !!tpl(i, "B")).length
|
||||||
|
const stepsWithoutTest = STEP_META.filter((_, i) => !tpl(i, "B")).length
|
||||||
|
const neverOptimised = testsRunning === 0 && templates.every(t => t.variant === "A")
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────
|
||||||
|
|
||||||
|
const optimiseAll = async () => {
|
||||||
|
setAiWorking(true)
|
||||||
|
// Generate challengers for all steps that don't have one
|
||||||
|
for (let step = 0; step < 4; step++) {
|
||||||
|
if (tpl(step, "B")) continue // already has a test
|
||||||
|
try {
|
||||||
|
await fetch("/api/automations/ai", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "generate_variant", step, channel: "whatsapp" }),
|
||||||
|
})
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
setAiWorking(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickWinnersAndContinue = async () => {
|
||||||
|
setAiWorking(true)
|
||||||
|
try {
|
||||||
|
await fetch("/api/automations/ai", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "check_winners" }),
|
||||||
|
})
|
||||||
|
} catch { /* */ }
|
||||||
|
await load()
|
||||||
|
setAiWorking(false)
|
||||||
|
}
|
||||||
|
|
||||||
const startEdit = (step: number) => {
|
const startEdit = (step: number) => {
|
||||||
const t = tpl(step)
|
const t = tpl(step)
|
||||||
if (t) { setEditBody(t.body); setEditing(step) }
|
if (t) { setEditBody(t.body); setEditing(step) }
|
||||||
setTimeout(() => editorRef.current?.focus(), 50)
|
setTimeout(() => editorRef.current?.focus(), 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelEdit = () => { setEditing(null); setEditBody("") }
|
|
||||||
|
|
||||||
const saveEdit = async (step: number) => {
|
const saveEdit = async (step: number) => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const t = tpl(step)
|
const t = tpl(step)
|
||||||
@@ -98,41 +137,6 @@ export default function AutomationsPage() {
|
|||||||
setSaving(false)
|
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) => {
|
const saveTiming = async (key: string, value: number) => {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/automations", {
|
await fetch("/api/automations", {
|
||||||
@@ -146,39 +150,85 @@ export default function AutomationsPage() {
|
|||||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
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 waConnected = !!channels?.whatsapp
|
||||||
const anyAB = templates.some(t => t.variant === "B")
|
|
||||||
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
|
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto space-y-5">
|
<div className="max-w-lg mx-auto space-y-5">
|
||||||
|
|
||||||
{/* Header — one line */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-[#111827] tracking-tight">What your donors receive</h1>
|
<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>
|
<p className="text-xs text-gray-500 mt-1">4 messages over {delays[3]} days. Click any to edit.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status line */}
|
{/* WhatsApp status */}
|
||||||
{!waConnected ? (
|
{!waConnected && (
|
||||||
<div className="border-l-2 border-[#F59E0B] bg-[#FEF3C7] px-4 py-3">
|
<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>
|
<p className="text-xs text-[#111827]"><strong>WhatsApp not connected.</strong> Messages start sending once you <Link href="/dashboard/settings" className="text-[#1E40AF] font-bold underline">connect WhatsApp</Link>.</p>
|
||||||
</div>
|
</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 */}
|
{/* ── AI HERO — the main CTA ── */}
|
||||||
{anyAB && (
|
{neverOptimised ? (
|
||||||
<button onClick={pickWinners} disabled={aiLoading === -1}
|
/* State 1: Never optimised */
|
||||||
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">
|
<div className="bg-[#111827] p-6">
|
||||||
{aiLoading === -1 ? <><Loader2 className="h-3 w-3 animate-spin" /> Checking…</> : <><Trophy className="h-3 w-3" /> Pick winners & start new tests</>}
|
<div className="flex items-start gap-3">
|
||||||
|
<Sparkles className="h-5 w-5 text-[#60A5FA] mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-white">Let AI improve your messages</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 leading-relaxed">
|
||||||
|
AI writes a different version of each message and tests both with real donors.
|
||||||
|
After enough responses, the better version wins automatically.
|
||||||
|
Your messages get better over time — without you doing anything.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={optimiseAll} disabled={aiWorking}
|
||||||
|
className="mt-4 w-full bg-white text-[#111827] py-3 text-sm font-bold flex items-center justify-center gap-2 hover:bg-gray-100 transition-colors disabled:opacity-60">
|
||||||
|
{aiWorking
|
||||||
|
? <><Loader2 className="h-4 w-4 animate-spin" /> AI is writing new versions…</>
|
||||||
|
: <><Sparkles className="h-4 w-4" /> Start optimising</>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
) : testsRunning > 0 ? (
|
||||||
|
/* State 2: Tests running */
|
||||||
|
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Sparkles className="h-5 w-5 text-[#60A5FA]" />
|
||||||
|
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-[#60A5FA] animate-pulse" style={{ borderRadius: "50%" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-white">AI is testing {testsRunning} experiment{testsRunning > 1 ? "s" : ""}</p>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
Each message has two versions. The better one wins automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stepsWithoutTest > 0 && (
|
||||||
|
<button onClick={optimiseAll} disabled={aiWorking}
|
||||||
|
className="shrink-0 bg-white/10 text-white px-3 py-1.5 text-[10px] font-bold hover:bg-white/20 transition-colors disabled:opacity-50">
|
||||||
|
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : `+ ${stepsWithoutTest} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* State 3: All tests resolved (or manually cleared) */
|
||||||
|
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||||
|
<Trophy className="h-5 w-5 text-[#4ADE80] shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-white">Messages optimised</p>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
{stats && stats.total > 0
|
||||||
|
? `${stats.total} sent this week · ${stats.deliveryRate}% delivered`
|
||||||
|
: "Winning versions are live. Run another round to keep improving."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={optimiseAll} disabled={aiWorking}
|
||||||
|
className="shrink-0 bg-white/10 text-white px-3 py-1.5 text-[10px] font-bold hover:bg-white/20 transition-colors disabled:opacity-50 flex items-center gap-1">
|
||||||
|
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : <><Sparkles className="h-3 w-3" /> New round</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── THE CONVERSATION ── */}
|
{/* ── THE CONVERSATION ── */}
|
||||||
@@ -196,7 +246,7 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat area */}
|
{/* Chat */}
|
||||||
<div className="bg-[#ECE5DD] px-4 py-4 space-y-4" style={{
|
<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")`,
|
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")`,
|
||||||
}}>
|
}}>
|
||||||
@@ -205,67 +255,123 @@ export default function AutomationsPage() {
|
|||||||
const b = tpl(step, "B")
|
const b = tpl(step, "B")
|
||||||
const isEditing = editing === step
|
const isEditing = editing === step
|
||||||
const justSaved = saved === step
|
const justSaved = saved === step
|
||||||
const isAiLoading = aiLoading === step
|
|
||||||
const delay = delays[step]
|
const delay = delays[step]
|
||||||
const previewA = a ? resolvePreview(a.body) : ""
|
const previewA = a ? resolvePreview(a.body) : ""
|
||||||
const previewB = b ? resolvePreview(b.body) : ""
|
const previewB = b ? resolvePreview(b.body) : ""
|
||||||
|
|
||||||
|
// A/B stats
|
||||||
|
const rateA = a && a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0
|
||||||
|
const rateB = b && b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0
|
||||||
|
const totalSent = (a?.sentCount || 0) + (b?.sentCount || 0)
|
||||||
|
const progress = Math.min(100, Math.round((totalSent / (MIN_SAMPLE * 2)) * 100))
|
||||||
|
const hasEnoughData = (a?.sentCount || 0) >= MIN_SAMPLE && (b?.sentCount || 0) >= MIN_SAMPLE
|
||||||
|
const winner = hasEnoughData ? (rateB > rateA ? "B" : rateA > rateB ? "A" : null) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step}>
|
<div key={step}>
|
||||||
{/* Timestamp divider */}
|
{/* Timestamp */}
|
||||||
<div className="flex justify-center mb-3">
|
<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" }}>
|
<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`}
|
{step === 0 ? "Instantly" : `Day ${delay} · if not paid`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* A/B split — two bubbles stacked */}
|
{isEditing ? (
|
||||||
{b && !isEditing ? (
|
/* ── Editing ── */
|
||||||
<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="flex justify-end">
|
||||||
<div className="bg-[#DCF8C6] max-w-[90%] w-full shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
<div className="bg-[#DCF8C6] max-w-[90%] w-full shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
||||||
<textarea
|
<textarea ref={editorRef} value={editBody} onChange={e => setEditBody(e.target.value)}
|
||||||
ref={editorRef}
|
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" />
|
||||||
value={editBody}
|
<div className="px-3 pb-2 flex items-center gap-2">
|
||||||
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}
|
<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" }}>
|
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
|
{saving ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Check className="h-2.5 w-2.5" />} Save
|
||||||
</button>
|
</button>
|
||||||
<button onClick={cancelEdit} className="text-[10px] text-[#667781] hover:text-[#303030]">Cancel</button>
|
<button onClick={() => setEditing(null)} className="text-[10px] text-[#667781]">Cancel</button>
|
||||||
<span className="text-[9px] text-[#667781]/50 ml-auto">
|
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{reference}}"}</span>
|
||||||
Use {"{{name}}"} {"{{amount}}"} {"{{reference}}"}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : b ? (
|
||||||
|
/* ── A/B test in progress ── */
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
||||||
|
{/* Test header */}
|
||||||
|
<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 this message</span>
|
||||||
|
{hasEnoughData && winner && (
|
||||||
|
<span className="text-[9px] bg-[#4ADE80]/20 text-[#4ADE80] px-1.5 py-0.5 font-bold flex items-center gap-0.5">
|
||||||
|
<Trophy className="h-2.5 w-2.5" /> {winner === "B" ? "AI" : "Yours"} winning
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two versions side by side */}
|
||||||
|
<div className="grid grid-cols-2 gap-px bg-[#075E54]/20">
|
||||||
|
{/* Your version */}
|
||||||
|
<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} paid / {a.sentCount} sent</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* AI version */}
|
||||||
|
<div className="bg-[#DCF8C6] p-2.5">
|
||||||
|
<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} paid / {b.sentCount} sent</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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-[#4ADE80]" : "bg-[#60A5FA]"}`}
|
||||||
|
style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-[#667781] mt-1">
|
||||||
|
{hasEnoughData
|
||||||
|
? winner
|
||||||
|
? `${winner === "B" ? "AI" : "Your"} version converts ${Math.abs(rateB - rateA)}% better`
|
||||||
|
: "Too close to call — collecting more data"
|
||||||
|
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed for verdict`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Normal bubble ── */
|
/* ── Normal bubble ── */
|
||||||
<div className="flex justify-end group">
|
<div className="flex justify-end">
|
||||||
<button
|
<button onClick={() => startEdit(step)}
|
||||||
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-[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" }}
|
style={{ borderRadius: "8px 0 8px 8px" }}>
|
||||||
>
|
|
||||||
{justSaved && (
|
{justSaved && (
|
||||||
<div className="absolute -top-2 -right-2 w-5 h-5 bg-[#25D366] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
<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" />
|
<Check className="h-3 w-3 text-white" />
|
||||||
@@ -273,25 +379,12 @@ export default function AutomationsPage() {
|
|||||||
)}
|
)}
|
||||||
<WhatsAppFormatted text={previewA} />
|
<WhatsAppFormatted text={previewA} />
|
||||||
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
||||||
<span className="text-[9px] text-[#667781]">
|
<span className="text-[9px] text-[#667781]">{step === 0 ? "09:41" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"}</span>
|
||||||
{step === 0 ? "09:41" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"}
|
|
||||||
</span>
|
|
||||||
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -308,7 +401,18 @@ export default function AutomationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Timing (expandable) ── */}
|
{/* Pick winners — only when tests have enough data */}
|
||||||
|
{testsRunning > 0 && (
|
||||||
|
<button onClick={pickWinnersAndContinue} disabled={aiWorking}
|
||||||
|
className="w-full bg-[#111827] text-white py-3 text-xs 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</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
<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">
|
<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
|
<Clock className="h-3 w-3" /> Change timing
|
||||||
<ChevronDown className={`h-3 w-3 transition-transform ${showTiming ? "rotate-180" : ""}`} />
|
<ChevronDown className={`h-3 w-3 transition-transform ${showTiming ? "rotate-180" : ""}`} />
|
||||||
@@ -335,41 +439,6 @@ export default function AutomationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── 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 ────────────────────────────────
|
// ─── WhatsApp Formatted Text ────────────────────────────────
|
||||||
|
|
||||||
function WhatsAppFormatted({ text }: { text: string }) {
|
function WhatsAppFormatted({ text }: { text: string }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user