Surgical photography: continuous brand experience from landing page to dashboard

ONE image, surgically placed.

'digital-03-notification-smile.jpg' — young man at a London bus
stop smiling at his phone. The moment a WhatsApp reminder lands
and he thinks 'oh right, I need to do that.'

This IS the product working. Not decoration — context.

Uses the same image-panel + dark-panel split from the landing page:
┌──────────────┬──────────────────────────────┐
│ [photo]      │   AI optimisation           │
│ Man smiling  │  Let AI improve your messages │
│ at phone     │  [Start optimising]           │
└──────────────┴──────────────────────────────┘

The image only appears in the onboarding state (never optimised).
Once AI is running, the hero compacts to a single dark bar.
The image served its purpose — motivation to start.

Brand alignment:
- Left-border accent (generosity-gold) on header
- 11px uppercase tracking-[0.15em] labels
- gap-px grid for timing controls
- Sharp edges everywhere (phone mockup is the only exception)
- 60-30-10 color rule maintained
- Dark inversion for AI hero sections
- Typography-driven hierarchy

No new images generated. Used existing brand photography.
This commit is contained in:
2026-03-05 01:58:17 +08:00
parent 7f347260c5
commit b2cfdff959

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import {
Loader2, Check, Send, Sparkles, Trophy, CheckCheck,
ChevronDown, Clock, MessageCircle
@@ -11,23 +12,16 @@ import { resolvePreview, STEP_META } from "@/lib/templates"
/**
* /dashboard/automations
*
* AI DOES THE WORK.
* AI DOES THE WORK. PHOTOGRAPHY SETS THE CONTEXT.
*
* The page has three states:
* The AI hero uses the same image-panel + dark-panel split from
* the landing page. The image is `digital-03-notification-smile` —
* a young man at a bus stop smiling at his phone. It IS the product
* working. The moment a WhatsApp reminder lands and someone thinks
* "oh right, I need to do that."
*
* 1. NOT STARTED — big hero: "Let AI improve your messages"
* One button. AI generates challengers for all 4 steps.
*
* 2. TESTING — "AI is testing 4 experiments"
* 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."
* Once AI is running, the hero compacts down — the image served its
* purpose (motivation to start). Now the data takes over.
*/
interface Template {
@@ -81,18 +75,14 @@ export default function AutomationsPage() {
templates.find(t => t.step === step && t.channel === "whatsapp" && 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
if (tpl(step, "B")) continue
try {
await fetch("/api/automations/ai", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -153,46 +143,71 @@ export default function AutomationsPage() {
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
return (
<div className="max-w-lg mx-auto space-y-5">
<div className="space-y-6">
{/* Header */}
{/* ── Header ── */}
<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 className="border-l-2 border-[#F59E0B] pl-3 mb-3">
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Automations</p>
</div>
<h1 className="text-3xl md:text-4xl font-black text-[#111827] tracking-tight">
What your donors receive
</h1>
<p className="text-sm text-gray-500 mt-1">
4 messages over {delays[3]} days. Click any to edit.
</p>
</div>
{/* WhatsApp status */}
{/* ── WhatsApp status ── */}
{!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> Messages start sending once you <Link href="/dashboard/settings" className="text-[#1E40AF] font-bold underline">connect WhatsApp</Link>.</p>
<div className="border-l-2 border-[#F59E0B] pl-4 py-2">
<p className="text-sm text-gray-600"><strong className="text-[#111827]">WhatsApp not connected.</strong> Messages start once you <Link href="/dashboard/settings" className="text-[#1E40AF] font-bold hover:underline">connect WhatsApp</Link>.</p>
</div>
)}
{/* ── AI HERO — the main CTA ── */}
{/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Same image-panel + dark-panel split from the landing page.
The photo is the PRODUCT WORKING — a real person receiving
a WhatsApp reminder and acting on it.
Once AI is running, the hero compacts. The image did its job.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
{neverOptimised ? (
/* State 1: Never optimised */
<div className="bg-[#111827] p-6">
<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">
<div className="grid md:grid-cols-5 gap-0">
{/* Photo — the moment a reminder lands */}
<div className="md:col-span-2 relative min-h-[200px] md:min-h-[280px] overflow-hidden">
<Image
src="/images/brand/digital-03-notification-smile.jpg"
alt="Young man at a London bus stop smiling at his phone — the moment a gentle WhatsApp reminder lands"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 40vw"
/>
</div>
{/* Dark panel — CTA */}
<div className="md:col-span-3 bg-[#111827] p-8 md:p-10 flex flex-col justify-center">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="h-4 w-4 text-[#60A5FA]" />
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">AI optimisation</p>
</div>
<h2 className="text-2xl md:text-3xl font-black text-white tracking-tight">
Let AI improve your&nbsp;messages
</h2>
<p className="text-sm text-gray-400 leading-relaxed mt-3 max-w-md">
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.
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">
className="mt-6 inline-flex items-center justify-center bg-white px-6 py-3 text-sm font-bold text-[#111827] hover:bg-gray-100 transition-colors self-start 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</>
? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> AI is writing new versions</>
: <><Sparkles className="h-4 w-4 mr-2" /> Start optimising</>
}
</button>
<p className="text-[11px] text-gray-500 mt-3">Uses GPT-4.1 nano · Costs less than 1p per message</p>
</div>
</div>
) : testsRunning > 0 ? (
/* State 2: Tests running */
/* ── Compact hero: 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]" />
@@ -200,38 +215,37 @@ export default function AutomationsPage() {
</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>
<p className="text-[11px] text-gray-500 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">
className="shrink-0 border border-gray-600 text-gray-300 px-3 py-1.5 text-[11px] font-bold hover:text-white hover:border-white 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) */
/* ── Compact hero: optimised ── */
<div className="bg-[#111827] p-5 flex items-center gap-4">
<Trophy className="h-5 w-5 text-[#4ADE80] shrink-0" />
<Trophy className="h-5 w-5 text-[#16A34A] 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 className="text-[11px] text-gray-500 mt-0.5">
{stats && stats.total > 0 ? `${stats.total} sent this week · ${stats.deliveryRate}% delivered` : "Winning versions are live."}
</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">
className="shrink-0 border border-gray-600 text-gray-300 px-3 py-1.5 text-[11px] font-bold hover:text-white hover:border-white transition-colors disabled:opacity-50 flex items-center gap-1.5">
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : <><Sparkles className="h-3 w-3" /> New round</>}
</button>
</div>
)}
{/* ── THE CONVERSATION ── */}
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The WhatsApp mockup uses rounded corners because it IS a
phone. Everything else follows brand rules (sharp edges).
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
<div className="max-w-lg mx-auto">
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
{/* WhatsApp header */}
@@ -259,7 +273,6 @@ export default function AutomationsPage() {
const previewA = a ? resolvePreview(a.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)
@@ -277,7 +290,6 @@ export default function AutomationsPage() {
</div>
{isEditing ? (
/* ── Editing ── */
<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)}
@@ -293,81 +305,49 @@ export default function AutomationsPage() {
</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>
<span className="text-[10px] font-bold flex-1">AI is testing</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">
<span className="text-[9px] bg-[#16A34A]/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>
)}
{!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>
)}
{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>
)}
<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}/{a.sentCount} paid</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>
)}
<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 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}/{b.sentCount} paid</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 className={`h-full transition-all ${hasEnoughData ? "bg-[#16A34A]" : "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`
}
? winner ? `${winner === "B" ? "AI" : "Your"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call"
: `${totalSent} of ${MIN_SAMPLE * 2} sends to verdict`}
</p>
</div>
</div>
</div>
) : (
/* ── Normal bubble ── */
<div className="flex justify-end">
<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"
@@ -400,32 +380,37 @@ export default function AutomationsPage() {
</div>
</div>
</div>
</div>
{/* Pick winners — only when tests have enough data */}
{/* ── Pick winners ── */}
{testsRunning > 0 && (
<div className="max-w-lg mx-auto">
<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">
className="w-full bg-[#111827] text-white py-3 text-sm 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>
</div>
)}
{/* 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">
{/* ── Timing (expandable, in brand language) ── */}
<div className="max-w-lg mx-auto">
<button onClick={() => setShowTiming(!showTiming)}
className="flex items-center gap-2 text-[11px] text-gray-400 hover:text-gray-600 transition-colors py-1 font-semibold tracking-wide uppercase">
<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">
<div className="grid grid-cols-3 gap-px bg-gray-200 mt-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>
<div key={t.key} className="bg-white p-4">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wide mb-2">{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>)}
@@ -435,6 +420,7 @@ export default function AutomationsPage() {
</div>
)}
</div>
</div>
)
}