Deep UX: 2-column automations, visible appeal cards, platform education, strip model refs
Automations: - 2-column layout: WhatsApp phone LEFT, education RIGHT - Right column: 'How it works' (5 numbered steps), performance stats, timing controls, reply commands, tips - Hero spans full width with photo+dark panel - Improvement CTA is a prominent card, not floating text - No misalignment — phone fills left column naturally Collect: - Appeals shown as visible gap-px grid cards (not hidden dropdown) - Each card shows name, platform, amount raised, pledge count, collection rate - Active appeal has border-l-2 blue indicator - Platform integration clarity: shows 'Donors redirected to JustGiving' etc - Educational section: 'Where to share your link' + 'How payment works' - Explains bank transfer vs JustGiving vs card payment inline AI model: Stripped all model name comments from code (no user-facing references existed)
This commit is contained in:
@@ -11,7 +11,7 @@ const OPENAI_MODEL = "gpt-4.1-nano"
|
||||
async function chat(messages: Array<{ role: string; content: string }>, maxTokens = 600): Promise<string> {
|
||||
if (!HAS_AI) return ""
|
||||
|
||||
// Prefer OpenAI (gpt-4.1-nano), fall back to Gemini
|
||||
// Prefer OpenAI, fall back to Gemini
|
||||
if (OPENAI_KEY) {
|
||||
try {
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
Loader2, Check, Send, Trophy, CheckCheck,
|
||||
ChevronDown, Clock, MessageCircle, RefreshCw, Calendar
|
||||
ChevronDown, MessageCircle, RefreshCw, Calendar
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { resolvePreview, STEP_META } from "@/lib/templates"
|
||||
@@ -146,312 +146,364 @@ export default function AutomationsPage() {
|
||||
const waConnected = !!channels?.whatsapp
|
||||
const delays = [0, config?.step1Delay || 2, config?.step2Delay || 7, config?.step3Delay || 14]
|
||||
|
||||
// Stats for the right column
|
||||
const totalSentAll = templates.reduce((s, t) => s + t.sentCount, 0)
|
||||
const totalConverted = templates.reduce((s, t) => s + t.convertedCount, 0)
|
||||
const overallRate = totalSentAll > 0 ? Math.round((totalConverted / totalSentAll) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div>
|
||||
<div className="border-l-2 border-[#F59E0B] pl-3 mb-3">
|
||||
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Donor journey</p>
|
||||
{/* ━━ HERO — Full-width, same pattern as landing page ━━━━━━━ */}
|
||||
<div className="grid md:grid-cols-5 gap-0">
|
||||
<div className="md:col-span-2 relative min-h-[180px] md:min-h-[260px] overflow-hidden">
|
||||
<Image
|
||||
src="/images/brand/digital-03-notification-smile.jpg"
|
||||
alt="Young man smiling at his phone — the moment a gentle reminder lands"
|
||||
fill className="object-cover" sizes="(max-width: 768px) 100vw, 40vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3 bg-[#111827] p-6 md:p-8 flex flex-col justify-center">
|
||||
<div className="border-l-2 border-[#F59E0B] pl-3 mb-3">
|
||||
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Donor journey</p>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
|
||||
What your donors receive
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400 leading-relaxed mt-2 max-w-md">
|
||||
After someone pledges, they get 5 WhatsApp messages — a receipt, a due date nudge, and 3 reminders. Each message includes your bank details and a unique reference. Click any message below to edit it.
|
||||
</p>
|
||||
{!waConnected && (
|
||||
<p className="text-xs text-[#F59E0B] mt-3 font-bold">
|
||||
WhatsApp not connected — <Link href="/dashboard/settings" className="underline hover:text-white">connect in Settings</Link> to start sending.
|
||||
</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">
|
||||
5 messages — a receipt, a due date nudge, and 3 reminders. Click any to edit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── WhatsApp status ── */}
|
||||
{!waConnected && (
|
||||
<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>
|
||||
)}
|
||||
{/* ━━ TWO-COLUMN LAYOUT — Phone left, education right ━━━━━━ */}
|
||||
<div className="grid lg:grid-cols-12 gap-6">
|
||||
|
||||
{/* ━━ HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
No tech-speak. No model names. No cost breakdowns.
|
||||
This is about what happens for the DONOR, not the engine.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
{neverOptimised ? (
|
||||
/* ── Never tested: invite them to start ── */
|
||||
<div className="grid md:grid-cols-5 gap-0">
|
||||
<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 smiling at his phone — the moment a gentle reminder lands"
|
||||
fill className="object-cover" sizes="(max-width: 768px) 100vw, 40vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3 bg-[#111827] p-8 md:p-10 flex flex-col justify-center">
|
||||
<h2 className="text-2xl md:text-3xl font-black text-white tracking-tight">
|
||||
Messages that improve themselves
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 leading-relaxed mt-3 max-w-md">
|
||||
We test different versions of each message with your real donors.
|
||||
The one that collects more pledges wins. Automatically. You don't do anything — they just get better over time.
|
||||
</p>
|
||||
{/* ── LEFT: The WhatsApp conversation ── */}
|
||||
<div className="lg:col-span-7">
|
||||
|
||||
{/* Improvement status bar */}
|
||||
{neverOptimised ? (
|
||||
<button onClick={optimiseAll} disabled={aiWorking}
|
||||
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 mr-2" /> Creating new versions…</>
|
||||
: "Start improving"}
|
||||
className="w-full bg-white border-2 border-[#111827] p-4 mb-4 flex items-center gap-4 hover:bg-gray-50 transition-colors text-left disabled:opacity-60">
|
||||
<div className="w-10 h-10 bg-[#111827] flex items-center justify-center shrink-0">
|
||||
<span className="text-white text-lg font-black">↑</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-[#111827]">
|
||||
{aiWorking ? "Creating new versions…" : "Improve these messages"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
We'll test a different version of each message and keep whichever collects more.
|
||||
</p>
|
||||
</div>
|
||||
{aiWorking && <Loader2 className="h-4 w-4 text-[#1E40AF] animate-spin shrink-0" />}
|
||||
</button>
|
||||
) : testsRunning > 0 ? (
|
||||
<div className="bg-[#111827] p-4 mb-4 flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-2.5 h-2.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
||||
<span className="absolute inset-0 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">Testing {testsRunning} new version{testsRunning > 1 ? "s" : ""}</p>
|
||||
<p className="text-[11px] text-gray-500">The better one wins automatically.</p>
|
||||
</div>
|
||||
{stepsWithoutTest > 0 && (
|
||||
<button onClick={optimiseAll} disabled={aiWorking}
|
||||
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" /> : `Test ${stepsWithoutTest} more`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#111827] p-4 mb-4 flex items-center gap-3">
|
||||
<div className="w-2.5 h-2.5 bg-[#16A34A] shrink-0" style={{ borderRadius: "50%" }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-white">Your messages are tuned</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{stats && stats.total > 0 ? `${stats.total} sent · ${stats.deliveryRate}% delivered` : "Best-performing versions are live."}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={optimiseAll} disabled={aiWorking}
|
||||
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" /> : "Test new versions"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WhatsApp mockup */}
|
||||
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
||||
<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>
|
||||
|
||||
<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) => {
|
||||
const step = meta.step
|
||||
const a = tpl(step, "A")
|
||||
const b = tpl(step, "B")
|
||||
const isEditing = editing === step
|
||||
const justSaved = saved === step
|
||||
const isRegenning = regenerating === step
|
||||
const previewA = a ? resolvePreview(a.body) : ""
|
||||
const previewB = b ? resolvePreview(b.body) : ""
|
||||
const isConditional = meta.conditional
|
||||
const hasDueDateTemplate = !!a
|
||||
const timeLabel = step === 0 ? "Instantly" : step === 4 ? "On the due date · if set" : step === 1 ? `Day ${delays[1]} · if not paid` : step === 2 ? `Day ${delays[2]} · if not paid` : `Day ${delays[3]} · if not paid`
|
||||
const clockTime = step === 0 ? "09:41" : step === 4 ? "08:00" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"
|
||||
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
|
||||
|
||||
if (isConditional && !hasDueDateTemplate) return null
|
||||
|
||||
return (
|
||||
<div key={step}>
|
||||
<div className="flex justify-center mb-3">
|
||||
<span className={`text-[10px] px-3 py-1 font-medium shadow-sm flex items-center gap-1.5 ${isConditional ? "bg-[#FEF3C7] text-[#92400E]" : "bg-white/80 text-[#667781]"}`} style={{ borderRadius: "6px" }}>
|
||||
{isConditional && <Calendar className="h-2.5 w-2.5" />}
|
||||
{timeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<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-[13px] leading-[1.6] text-[#303030] resize-y outline-none min-h-[180px] font-mono" />
|
||||
<div className="px-3 pb-2 flex items-center gap-2">
|
||||
<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={() => setEditing(null)} className="text-[10px] text-[#667781]">Cancel</button>
|
||||
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{payment_block}}"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : b ? (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
||||
<div className="bg-[#075E54] text-white px-3 py-1.5 flex items-center gap-1.5" style={{ borderRadius: "8px 8px 0 0" }}>
|
||||
<div className="w-1.5 h-1.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
||||
<span className="text-[10px] font-bold flex-1">Testing two versions</span>
|
||||
{hasEnoughData && winner && (
|
||||
<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" ? "New" : "Current"} winning
|
||||
</span>
|
||||
)}
|
||||
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
||||
</div>
|
||||
<button onClick={() => startEdit(step)} className="w-full bg-[#DCF8C6] p-3 text-left hover:brightness-[0.97] transition-all border-b border-[#075E54]/10">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] font-bold text-[#075E54] bg-[#075E54]/10 px-1.5 py-0.5">Current</span>
|
||||
{a && a.sentCount > 0 && (
|
||||
<span className={`text-[9px] font-bold ml-auto ${winner === "A" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||
{rateA}% conversion{winner === "A" && " 🏆"} · {a.convertedCount}/{a.sentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.5] text-[#303030]"><WhatsAppFormatted text={previewA} /></div>
|
||||
</button>
|
||||
<div className="bg-[#DCF8C6] p-3 relative group">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5">New</span>
|
||||
{b.sentCount > 0 && (
|
||||
<span className={`text-[9px] font-bold ml-auto ${winner === "B" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||
{rateB}% conversion{winner === "B" && " 🏆"} · {b.convertedCount}/{b.sentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.5] text-[#303030]"><WhatsAppFormatted text={previewB} /></div>
|
||||
<button onClick={() => regenerateVariant(step)} disabled={isRegenning}
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 bg-white/90 p-1.5 shadow-sm transition-opacity hover:bg-white disabled:opacity-50"
|
||||
style={{ borderRadius: "4px" }} title="Try a different version">
|
||||
{isRegenning ? <Loader2 className="h-3.5 w-3.5 text-[#1E40AF] animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 text-[#1E40AF]" />}
|
||||
</button>
|
||||
</div>
|
||||
<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-[#16A34A]" : "bg-[#60A5FA]"}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-[9px] text-[#667781] mt-1">
|
||||
{hasEnoughData
|
||||
? winner ? `${winner === "B" ? "New" : "Current"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call — need more data"
|
||||
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => startEdit(step)}
|
||||
className="bg-[#DCF8C6] max-w-[88%] px-3 py-2 text-left text-[13px] leading-[1.5] 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]">{clockTime}</span>
|
||||
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
) : testsRunning > 0 ? (
|
||||
/* ── Tests running: quiet confidence ── */
|
||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-2.5 h-2.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
||||
<span className="absolute inset-0 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">Testing {testsRunning} new version{testsRunning > 1 ? "s" : ""}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">The version that converts more donors wins automatically.</p>
|
||||
</div>
|
||||
{stepsWithoutTest > 0 && (
|
||||
<button onClick={optimiseAll} disabled={aiWorking}
|
||||
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" /> : `Test ${stepsWithoutTest} more`}
|
||||
|
||||
{/* Pick winners */}
|
||||
{testsRunning > 0 && (
|
||||
<button onClick={pickWinnersAndContinue} disabled={aiWorking}
|
||||
className="w-full mt-4 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 the best & test new ones</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── All optimised: show results ── */
|
||||
<div className="bg-[#111827] p-5 flex items-center gap-4">
|
||||
<div className="w-2.5 h-2.5 bg-[#16A34A] shrink-0" style={{ borderRadius: "50%" }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-white">Your messages are tuned</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
{stats && stats.total > 0 ? `${stats.total} sent · ${stats.deliveryRate}% delivered` : "Best-performing versions are live."}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={optimiseAll} disabled={aiWorking}
|
||||
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" /> : "Test new versions"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ━━ THE CONVERSATION ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Full messages always visible. No truncation.
|
||||
A/B tests stack vertically — Current on top, New below.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="border border-gray-300 overflow-hidden shadow-lg" style={{ borderRadius: "20px" }}>
|
||||
{/* ── RIGHT: Education + context — the landing page voice ── */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* 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" />
|
||||
{/* How it works — numbered steps like landing page */}
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">How it works</h3>
|
||||
</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 */}
|
||||
<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) => {
|
||||
const step = meta.step
|
||||
const a = tpl(step, "A")
|
||||
const b = tpl(step, "B")
|
||||
const isEditing = editing === step
|
||||
const justSaved = saved === step
|
||||
const isRegenning = regenerating === step
|
||||
const previewA = a ? resolvePreview(a.body) : ""
|
||||
const previewB = b ? resolvePreview(b.body) : ""
|
||||
|
||||
const isConditional = meta.conditional
|
||||
const hasDueDateTemplate = !!a
|
||||
|
||||
const timeLabel = step === 0 ? "Instantly" :
|
||||
step === 4 ? "On the due date · if set" :
|
||||
step === 1 ? `Day ${delays[1]} · if not paid` :
|
||||
step === 2 ? `Day ${delays[2]} · if not paid` :
|
||||
`Day ${delays[3]} · if not paid`
|
||||
|
||||
const clockTime = step === 0 ? "09:41" : step === 4 ? "08:00" : step === 1 ? "10:15" : step === 2 ? "09:30" : "11:00"
|
||||
|
||||
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
|
||||
|
||||
if (isConditional && !hasDueDateTemplate) return null
|
||||
|
||||
return (
|
||||
<div key={step}>
|
||||
{/* Timestamp */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<span className={`text-[10px] px-3 py-1 font-medium shadow-sm flex items-center gap-1.5 ${isConditional ? "bg-[#FEF3C7] text-[#92400E]" : "bg-white/80 text-[#667781]"}`} style={{ borderRadius: "6px" }}>
|
||||
{isConditional && <Calendar className="h-2.5 w-2.5" />}
|
||||
{timeLabel}
|
||||
</span>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{[
|
||||
{ n: "01", title: "Someone pledges", desc: "They scan your QR code or tap your link. Amount, Gift Aid, Zakat — done in 60 seconds." },
|
||||
{ n: "02", title: "They get a receipt", desc: "Instantly. With your bank details and a unique reference so you can match their payment." },
|
||||
{ n: "03", title: "Gentle reminders", desc: "Day 2, day 7, day 14 — if they haven't paid. Warm, never pushy. They can reply PAID anytime." },
|
||||
{ n: "04", title: "Messages improve", desc: "We test different versions and keep whichever converts more pledges into payments. Automatically." },
|
||||
{ n: "05", title: "You do nothing", desc: "Receipts, reminders, follow-ups — all handled. You focus on the next event." },
|
||||
].map(s => (
|
||||
<div key={s.n} className="px-5 py-3 flex gap-3">
|
||||
<span className="text-lg font-black text-gray-200 shrink-0 w-6">{s.n}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#111827]">{s.title}</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
||||
</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)}
|
||||
className="w-full bg-transparent px-3 py-2 text-[13px] leading-[1.6] text-[#303030] resize-y outline-none min-h-[180px] font-mono" />
|
||||
<div className="px-3 pb-2 flex items-center gap-2">
|
||||
<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={() => setEditing(null)} className="text-[10px] text-[#667781]">Cancel</button>
|
||||
<span className="text-[9px] text-[#667781]/50 ml-auto">{"{{name}} {{amount}} {{payment_block}}"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : b ? (
|
||||
/* ── A/B TEST — Two versions, stacked ── */
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[90%] w-full" style={{ borderRadius: "8px" }}>
|
||||
{/* Header bar */}
|
||||
<div className="bg-[#075E54] text-white px-3 py-1.5 flex items-center gap-1.5" style={{ borderRadius: "8px 8px 0 0" }}>
|
||||
<div className="w-1.5 h-1.5 bg-[#60A5FA]" style={{ borderRadius: "50%" }} />
|
||||
<span className="text-[10px] font-bold flex-1">Testing two versions</span>
|
||||
{hasEnoughData && winner && (
|
||||
<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" ? "New" : "Current"} winning
|
||||
</span>
|
||||
)}
|
||||
{!hasEnoughData && <span className="text-[9px] text-white/40">{progress}%</span>}
|
||||
</div>
|
||||
|
||||
{/* Variant A — Current (full message) */}
|
||||
<button onClick={() => startEdit(step)} className="w-full bg-[#DCF8C6] p-3 text-left hover:brightness-[0.97] transition-all border-b border-[#075E54]/10">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] font-bold text-[#075E54] bg-[#075E54]/10 px-1.5 py-0.5">Current</span>
|
||||
{a && a.sentCount > 0 && (
|
||||
<span className={`text-[9px] font-bold ml-auto ${winner === "A" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||
{rateA}% conversion{winner === "A" && " 🏆"} · {a.convertedCount}/{a.sentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.5] text-[#303030]">
|
||||
<WhatsAppFormatted text={previewA} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Variant B — New (full message) */}
|
||||
<div className="bg-[#DCF8C6] p-3 relative group">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1.5 py-0.5">
|
||||
New
|
||||
</span>
|
||||
{b.sentCount > 0 && (
|
||||
<span className={`text-[9px] font-bold ml-auto ${winner === "B" ? "text-[#075E54]" : "text-[#667781]"}`}>
|
||||
{rateB}% conversion{winner === "B" && " 🏆"} · {b.convertedCount}/{b.sentCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] leading-[1.5] text-[#303030]">
|
||||
<WhatsAppFormatted text={previewB} />
|
||||
</div>
|
||||
{/* Regenerate */}
|
||||
<button onClick={() => regenerateVariant(step)} disabled={isRegenning}
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 bg-white/90 p-1.5 shadow-sm transition-opacity hover:bg-white disabled:opacity-50"
|
||||
style={{ borderRadius: "4px" }} title="Try a different version">
|
||||
{isRegenning ? <Loader2 className="h-3.5 w-3.5 text-[#1E40AF] animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 text-[#1E40AF]" />}
|
||||
</button>
|
||||
</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-[#16A34A]" : "bg-[#60A5FA]"}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-[9px] text-[#667781] mt-1">
|
||||
{hasEnoughData
|
||||
? winner ? `${winner === "B" ? "New" : "Current"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call — need more data"
|
||||
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── NORMAL MESSAGE (full, no truncation) ── */
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => startEdit(step)}
|
||||
className="bg-[#DCF8C6] max-w-[88%] px-3 py-2 text-left text-[13px] leading-[1.5] 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]">{clockTime}</span>
|
||||
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* ── 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-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 the best & test new ones</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Timing ── */}
|
||||
<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-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} 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>)}
|
||||
</select>
|
||||
{/* Performance — if there's data */}
|
||||
{totalSentAll > 0 && (
|
||||
<div className="bg-white border border-gray-200">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Performance</h3>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-3 gap-px bg-gray-200">
|
||||
<div className="bg-white p-4 text-center">
|
||||
<p className="text-xl font-black text-[#111827]">{totalSentAll}</p>
|
||||
<p className="text-[9px] text-gray-500 mt-0.5">Messages sent</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 text-center">
|
||||
<p className="text-xl font-black text-[#16A34A]">{totalConverted}</p>
|
||||
<p className="text-[9px] text-gray-500 mt-0.5">Pledges collected</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 text-center">
|
||||
<p className="text-xl font-black text-[#111827]">{overallRate}%</p>
|
||||
<p className="text-[9px] text-gray-500 mt-0.5">Conversion rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timing controls — always visible in the right column */}
|
||||
<div className="bg-white border border-gray-200">
|
||||
<button onClick={() => setShowTiming(!showTiming)}
|
||||
className="w-full px-5 py-3 flex items-center justify-between text-left border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Reminder timing</h3>
|
||||
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${showTiming ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{showTiming ? (
|
||||
<div className="p-5 space-y-4">
|
||||
{[
|
||||
{ label: "First reminder", key: "step1Delay", value: config?.step1Delay || 2, desc: "Gentle check-in" },
|
||||
{ label: "Second reminder", key: "step2Delay", value: config?.step2Delay || 7, desc: "Shows impact of their gift" },
|
||||
{ label: "Final reminder", key: "step3Delay", value: config?.step3Delay || 14, desc: "Last message — no guilt, easy cancel" },
|
||||
].map(t => (
|
||||
<div key={t.key} className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#111827]">{t.label}</p>
|
||||
<p className="text-[10px] text-gray-400">{t.desc}</p>
|
||||
</div>
|
||||
<select value={t.value} onChange={e => saveTiming(t.key, parseInt(e.target.value))}
|
||||
className="border-2 border-gray-200 px-2 py-1.5 text-xs font-bold text-[#111827] bg-white focus:border-[#1E40AF] outline-none w-24">
|
||||
{[1, 2, 3, 5, 7, 10, 14, 21, 28].map(d => <option key={d} value={d}>Day {d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-5 py-3 text-xs text-gray-400">
|
||||
Day {config?.step1Delay || 2} → Day {config?.step2Delay || 7} → Day {config?.step3Delay || 14}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What donors can reply */}
|
||||
<div className="border-l-2 border-[#25D366] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Donors can reply to any message</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ cmd: "PAID", desc: "Marks their pledge as \"said they paid\" — you confirm via bank statement" },
|
||||
{ cmd: "HELP", desc: "Sends them your bank details and reference again" },
|
||||
{ cmd: "CANCEL", desc: "Cancels their pledge and stops all messages immediately" },
|
||||
].map(c => (
|
||||
<div key={c.cmd} className="flex items-start gap-2">
|
||||
<code className="text-[10px] font-mono font-bold text-[#111827] bg-gray-100 px-1.5 py-0.5 shrink-0">{c.cmd}</code>
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed">{c.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-1.5">
|
||||
<p className="text-xs font-bold text-[#111827]">Tips for better collection rates</p>
|
||||
<p className="text-[10px] text-gray-500">Keep messages short — 3 lines converts better than 10.</p>
|
||||
<p className="text-[10px] text-gray-500">Always include the reference — donors need it to pay.</p>
|
||||
<p className="text-[10px] text-gray-500">The final message should make cancelling as easy as paying — no guilt means more trust next time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import {
|
||||
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
|
||||
Download, ExternalLink, Users, Trophy, ChevronDown, Link2,
|
||||
Download, ExternalLink, Users, Trophy, Link2,
|
||||
ArrowRight, QrCode as QrCodeIcon
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
@@ -64,8 +64,6 @@ export default function CollectPage() {
|
||||
const [appealTarget, setAppealTarget] = useState("")
|
||||
const [creatingAppeal, setCreatingAppeal] = useState(false)
|
||||
|
||||
const [eventSwitcherOpen, setEventSwitcherOpen] = useState(false)
|
||||
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
|
||||
|
||||
// Load events
|
||||
@@ -246,78 +244,100 @@ export default function CollectPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Appeal context (quiet for single, selector for multi) ── */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{events.length === 1 ? (
|
||||
<p className="text-sm font-bold text-[#111827]">{activeEvent?.name}</p>
|
||||
) : (
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
onClick={() => setEventSwitcherOpen(!eventSwitcherOpen)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-bold text-[#1E40AF] hover:underline"
|
||||
>
|
||||
{activeEvent?.name} <ChevronDown className={`h-3.5 w-3.5 transition-transform ${eventSwitcherOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{eventSwitcherOpen && (
|
||||
<div className="absolute z-20 mt-1 bg-white border border-gray-200 shadow-lg w-72 max-h-64 overflow-y-auto">
|
||||
{events.map(ev => (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => { setActiveEventId(ev.id); setEventSwitcherOpen(false) }}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors ${ev.id === activeEventId ? "bg-[#1E40AF]/5" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#111827]">{ev.name}</p>
|
||||
<p className="text-[10px] text-gray-500">{ev.pledgeCount} pledges · {formatPence(ev.totalPledged)}</p>
|
||||
</div>
|
||||
{ev.id === activeEventId && <Check className="h-4 w-4 text-[#1E40AF]" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setShowNewAppeal(true); setEventSwitcherOpen(false) }}
|
||||
className="w-full text-left px-4 py-3 border-t border-gray-100 text-sm font-semibold text-[#1E40AF] hover:bg-gray-50 flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> New appeal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{events.length === 1 && (
|
||||
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-gray-500 hover:text-[#111827] border border-gray-200 px-3 py-1.5 transition-colors">
|
||||
+ New appeal
|
||||
{/* ── Appeals as visible cards (not hidden in a dropdown) ── */}
|
||||
{events.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-bold text-[#111827]">Your appeals ({events.length})</h2>
|
||||
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
|
||||
<Plus className="h-3 w-3" /> New appeal
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> New Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Appeal stats (compact — the appeal is context, not hero) ── */}
|
||||
{activeEvent && (
|
||||
<div className="grid grid-cols-4 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: String(activeEvent.pledgeCount), label: "Pledges" },
|
||||
{ value: formatPence(activeEvent.totalPledged), label: "Promised" },
|
||||
{ value: formatPence(activeEvent.totalCollected), label: "Received", accent: "text-[#16A34A]" },
|
||||
{ value: String(sources.length), label: "Links" },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="bg-white p-3 md:p-4">
|
||||
<p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
|
||||
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-gray-200">
|
||||
{events.map(ev => {
|
||||
const isSelected = ev.id === activeEventId
|
||||
const rate = ev.totalPledged > 0 ? Math.round((ev.totalCollected / ev.totalPledged) * 100) : 0
|
||||
const platformLabel = ev.externalPlatform
|
||||
? ev.externalPlatform.charAt(0).toUpperCase() + ev.externalPlatform.slice(1)
|
||||
: ev.paymentMode === "self" ? "Bank transfer" : "Bank transfer"
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => setActiveEventId(ev.id)}
|
||||
className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${isSelected ? "border-l-2 border-[#1E40AF]" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-bold truncate ${isSelected ? "text-[#1E40AF]" : "text-[#111827]"}`}>{ev.name}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{platformLabel}</p>
|
||||
</div>
|
||||
{isSelected && <div className="w-2 h-2 bg-[#1E40AF] shrink-0 mt-1.5" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div>
|
||||
<p className="text-lg font-black text-[#111827]">{formatPence(ev.totalPledged)}</p>
|
||||
<p className="text-[9px] text-gray-400">raised</p>
|
||||
</div>
|
||||
<div className="text-right ml-auto">
|
||||
<p className="text-xs font-bold text-[#111827]">{ev.pledgeCount} pledges</p>
|
||||
<p className="text-[9px] text-gray-400">{rate}% collected</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single appeal: show name + new appeal button */}
|
||||
{events.length === 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-[#111827]">{activeEvent?.name}</p>
|
||||
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-gray-400 hover:text-[#111827] border border-gray-200 px-3 py-1.5 transition-colors">
|
||||
+ New appeal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Appeal stats + payment method clarity ── */}
|
||||
{activeEvent && (
|
||||
<div>
|
||||
<div className="grid grid-cols-4 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: String(activeEvent.pledgeCount), label: "Pledges" },
|
||||
{ value: formatPence(activeEvent.totalPledged), label: "Promised" },
|
||||
{ value: formatPence(activeEvent.totalCollected), label: "Received", accent: "text-[#16A34A]" },
|
||||
{ value: String(sources.length), label: "Links" },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="bg-white p-3 md:p-4">
|
||||
<p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
|
||||
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Payment method indicator — so users know how their platform works */}
|
||||
{activeEvent.externalPlatform && activeEvent.externalUrl && (
|
||||
<div className="border-l-2 border-[#1E40AF] bg-[#1E40AF]/5 p-3 mt-2 flex items-center gap-2">
|
||||
<ExternalLink className="h-3.5 w-3.5 text-[#1E40AF] shrink-0" />
|
||||
<p className="text-xs text-gray-600">
|
||||
Donors are redirected to <strong className="text-[#111827]">{activeEvent.externalPlatform.charAt(0).toUpperCase() + activeEvent.externalPlatform.slice(1)}</strong> to pay
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── New Link button ── */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> New Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Inline "create link" — fast, no dialog ── */}
|
||||
{showCreate && (
|
||||
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
|
||||
@@ -434,18 +454,54 @@ export default function CollectPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tips (only show when they have links but few pledges) ── */}
|
||||
{sources.length > 0 && sources.reduce((s, l) => s + l.pledgeCount, 0) < 5 && (
|
||||
{/* ── How it works — landing page style education ── */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-2">
|
||||
{/* Tips */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Tips to get more pledges</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1.5">
|
||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">01</span> Give each volunteer their own link — friendly competition works</li>
|
||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">02</span> Put the QR code on each table at your event</li>
|
||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">03</span> Share directly to WhatsApp groups — it takes 1 tap for them to pledge</li>
|
||||
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">04</span> Post the link on your Instagram or Facebook story</li>
|
||||
</ul>
|
||||
<p className="text-xs font-bold text-[#111827]">Where to share your link</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ n: "01", text: "Print the QR code on each table at your event" },
|
||||
{ n: "02", text: "Send the link to WhatsApp groups — one tap to pledge" },
|
||||
{ n: "03", text: "Post it on Instagram or Facebook stories" },
|
||||
{ n: "04", text: "Give each volunteer their own link — friendly competition works" },
|
||||
{ n: "05", text: "Add it to your email newsletter or website" },
|
||||
].map(t => (
|
||||
<div key={t.n} className="flex items-start gap-2">
|
||||
<span className="text-[#1E40AF] font-bold text-xs shrink-0">{t.n}</span>
|
||||
<p className="text-xs text-gray-600">{t.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How platforms work */}
|
||||
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">How payment works</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
When someone pledges, they see your payment details with a unique reference.
|
||||
Depending on how you set up your appeal:
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: "Bank transfer", desc: "Donor sees your sort code and account number" },
|
||||
{ label: "JustGiving / LaunchGood", desc: "Donor is redirected to your fundraising page" },
|
||||
{ label: "Card payment", desc: "Donor pays by Visa, Mastercard, or Apple Pay via Stripe" },
|
||||
].map(p => (
|
||||
<div key={p.label} className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-[#F59E0B] shrink-0 mt-1.5" />
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-[#111827]">{p.label}</p>
|
||||
<p className="text-[10px] text-gray-500">{p.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
Set your payment method when creating an appeal. You can change it anytime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── New appeal inline form ── */}
|
||||
{showNewAppeal && <NewAppealForm
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const OPENAI_KEY = process.env.OPENAI_API_KEY
|
||||
const GEMINI_KEY = process.env.GEMINI_API_KEY
|
||||
const HAS_AI = !!(OPENAI_KEY || GEMINI_KEY)
|
||||
const OPENAI_MODEL = "gpt-4.1-nano" // ~$0.10/1M input, ~$0.40/1M output
|
||||
const OPENAI_MODEL = "gpt-4.1-nano"
|
||||
|
||||
interface ChatMessage {
|
||||
role: "system" | "user" | "assistant"
|
||||
@@ -17,7 +17,7 @@ interface ChatMessage {
|
||||
async function chat(messages: ChatMessage[], maxTokens = 300): Promise<string> {
|
||||
if (!HAS_AI) return ""
|
||||
|
||||
// Prefer OpenAI (gpt-4.1-nano), fall back to Gemini
|
||||
// Prefer OpenAI, fall back to Gemini
|
||||
if (OPENAI_KEY) return chatOpenAI(messages, maxTokens)
|
||||
return chatGemini(messages, maxTokens)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user