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:
2026-03-05 01:50:26 +08:00
parent f1a8c59b0d
commit 7f347260c5

View File

@@ -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 &amp; 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 }) {