Platform overhaul: every dashboard page feels like a landing page

- Layout: Midnight header, white background, editorial sidebar
- Home: Brand photography hero with contextual state (empty/active/collected)
- Automations: ALL tech-speak stripped (no GPT model names, no cost per message, no 'AI optimisation' labels). Hero is about outcomes not engine. 'Current'/'New' labels replace 'Yours'/'AI'.
- Collect: Brand photography hero with event context
- Money: Dark hero with key financials + photography
- Reports: Landing page compliance-style financial hero
- Settings: Dark progress header with brand treatment

Brand DNA applied across all pages:
- Image + dark panel hero sections
- border-l-2 section labels
- gap-px grids for data
- Sharp edges, no rounded corners
- Human language throughout
- 60-30-10 color rule enforced
This commit is contained in:
2026-03-05 03:03:55 +08:00
parent 097f13f7be
commit 3c3336383e
7 changed files with 263 additions and 163 deletions

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import {
Loader2, Check, Send, Sparkles, Trophy, CheckCheck,
Loader2, Check, Send, Trophy, CheckCheck,
ChevronDown, Clock, MessageCircle, RefreshCw, Calendar
} from "lucide-react"
import Link from "next/link"
@@ -152,7 +152,7 @@ export default function AutomationsPage() {
{/* ── 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">Automations</p>
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Donor journey</p>
</div>
<h1 className="text-3xl md:text-4xl font-black text-[#111827] tracking-tight">
What your donors receive
@@ -169,73 +169,74 @@ export default function AutomationsPage() {
</div>
)}
{/* ━━ AI HERO ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */}
{/* ━━ 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 at a London bus stop smiling at his phone — the moment a gentle WhatsApp reminder lands"
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">
<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
Messages that improve&nbsp;themselves
</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.
The better version wins automatically. Your messages get better over time without you doing anything.
We test different versions of each message with your real donors.
The one that collects more pledges wins. Automatically. You don&apos;t do anything they just get better over time.
</p>
<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" /> AI is writing new versions</>
: <><Sparkles className="h-4 w-4 mr-2" /> Start optimising</>}
? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating new versions</>
: "Start improving"}
</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 ? (
/* ── Tests running: quiet confidence ── */
<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 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">AI is testing {testsRunning} experiment{testsRunning > 1 ? "s" : ""}</p>
<p className="text-[11px] text-gray-500 mt-0.5">Each message has two versions. The better one wins automatically.</p>
<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" /> : `+ ${stepsWithoutTest} more`}
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : `Test ${stepsWithoutTest} more`}
</button>
)}
</div>
) : (
/* ── All optimised: show results ── */
<div className="bg-[#111827] p-5 flex items-center gap-4">
<Trophy className="h-5 w-5 text-[#16A34A] shrink-0" />
<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">Messages optimised</p>
<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 this week · ${stats.deliveryRate}% delivered` : "Winning versions are live."}
{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 flex items-center gap-1.5">
{aiWorking ? <Loader2 className="h-3 w-3 animate-spin" /> : <><Sparkles className="h-3 w-3" /> New round</>}
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. No eclipse.
A/B tests stack vertically — Yours on top, AI below.
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" }}>
@@ -313,25 +314,25 @@ export default function AutomationsPage() {
</div>
</div>
) : b ? (
/* ── A/B TEST — FULL MESSAGES, STACKED VERTICALLY ── */
/* ── 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" }}>
<Sparkles className="h-3 w-3 text-[#60A5FA]" />
<span className="text-[10px] font-bold flex-1">AI is testing</span>
<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" ? "AI" : "Yours"} winning
<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 — Yours (full message) */}
{/* 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">Yours</span>
<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}
@@ -343,11 +344,11 @@ export default function AutomationsPage() {
</div>
</button>
{/* Variant B — AI (full message) */}
{/* 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 flex items-center gap-0.5">
<Sparkles className="h-2 w-2" /> AI
<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]"}`}>
@@ -361,7 +362,7 @@ export default function AutomationsPage() {
{/* 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 AI version">
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>
@@ -373,8 +374,8 @@ export default function AutomationsPage() {
</div>
<p className="text-[9px] text-[#667781] mt-1">
{hasEnoughData
? winner ? `${winner === "B" ? "AI" : "Your"} version converts ${Math.abs(rateB - rateA)}% better` : "Too close to call — need more data"
: `${totalSent} of ${MIN_SAMPLE * 2} sends needed for a verdict`}
? 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>
@@ -422,7 +423,7 @@ export default function AutomationsPage() {
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</>}
: <><Trophy className="h-3.5 w-3.5" /> Pick the best &amp; test new ones</>}
</button>
</div>
)}

View File

@@ -7,6 +7,7 @@ import {
Download, ExternalLink, Users, Trophy, ChevronDown, Link2,
ArrowRight, QrCode as QrCodeIcon
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
@@ -222,13 +223,34 @@ export default function CollectPage() {
return (
<div className="space-y-6">
{/* ── Header: Appeal context (quiet for single, selector for multi) ── */}
{/* ━━ HERO — Brand photography + context ━━━━━━━━━━━━━━━━━━━ */}
<div className="grid md:grid-cols-5 gap-0 mb-6">
<div className="md:col-span-2 relative min-h-[140px] md:min-h-[180px] overflow-hidden">
<Image
src="/images/brand/event-05-qr-scanning.jpg"
alt="Guest scanning a QR code at a fundraising event"
fill className="object-cover"
sizes="(max-width: 768px) 100vw, 40vw"
/>
</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">Collect</p>
</div>
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
Share your link, collect pledges
</h1>
<p className="text-sm text-gray-400 mt-2">
{activeEvent ? `${sources.length} link${sources.length !== 1 ? "s" : ""} · ${activeEvent.pledgeCount} pledges · ${formatPence(activeEvent.totalPledged)} raised` : "Create an appeal and start collecting"}
</p>
</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">
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
{events.length === 1 ? (
<p className="text-sm text-gray-500 mt-0.5">{activeEvent?.name}</p>
<p className="text-sm font-bold text-[#111827]">{activeEvent?.name}</p>
) : (
<div className="relative mt-1">
<button

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react"
import { formatPence } from "@/lib/utils"
import { Download, Loader2, FileText, Shield, Zap, Activity } from "lucide-react"
import Image from "next/image"
/**
* /dashboard/reports — "My treasurer needs numbers"
@@ -78,15 +79,35 @@ export default function ReportsPage() {
return (
<div className="space-y-8">
{/* ── Header ── */}
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Reports</h1>
<p className="text-sm text-gray-500 mt-0.5">Financial summary, Gift Aid, and data downloads for your treasurer and trustees</p>
{/* ━━ HERO — Financial summary as a landing page section ━━━ */}
<div className="grid md:grid-cols-5 gap-0">
<div className="md:col-span-2 relative min-h-[180px] md:min-h-[240px] overflow-hidden">
<Image
src="/images/brand/impact-03-cheque-presentation.jpg"
alt="Cheque presentation at a charity event"
fill className="object-cover"
sizes="(max-width: 768px) 100vw, 40vw"
/>
</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-4">
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Reports</p>
</div>
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight">
{formatPence(s.totalCollectedPence)}
<span className="text-gray-500 text-xl md:text-2xl ml-2">received</span>
</h1>
<p className="text-sm text-gray-400 mt-1">
{formatPence(s.totalPledgedPence)} promised · {s.collectionRate}% collection rate
</p>
<p className="text-xs text-gray-500 mt-3">
Financial summary, Gift Aid declarations, and data downloads for your treasurer.
</p>
</div>
</div>
{/* ── Financial summary — the big picture ── */}
{/* ── Financial breakdown ── */}
<div className="bg-[#111827] p-6">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">Financial Summary</p>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-700">
{[
{ value: formatPence(s.totalPledgedPence), label: "Total promised", color: "text-white" },

View File

@@ -4,15 +4,20 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession, signOut } from "next-auth/react"
import { useState, useEffect } from "react"
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle, Users, Zap } from "lucide-react"
import { Home, Megaphone, Banknote, FileText, Settings, LogOut, Shield, AlertTriangle, MessageCircle, Users, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* Navigation: goal-oriented, not feature-oriented
* Different nav for different roles:
* - Admin/Staff: Full nav (Home, Collect, Money, Reports, Settings)
* - Community Leader: Scoped nav (My Community, Collect, Reports)
* Dashboard shell — feels like the landing page, not a SaaS cockpit.
*
* Design DNA from the landing page:
* - Sharp edges (no rounded corners)
* - Midnight header
* - Border-l-2 active states
* - Generous typography
* - White background (not gray)
*/
const adminNavItems = [
{ href: "/dashboard", label: "Home", icon: Home },
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
@@ -39,7 +44,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const isCommunityLeader = user?.role === "community_leader"
const navItems = isCommunityLeader ? communityNavItems : adminNavItems
// Map old routes to new ones for active state
const isActive = (href: string) => {
if (href === "/dashboard") return pathname === "/dashboard"
if (href === "/dashboard/community") return pathname === "/dashboard/community" || pathname === "/dashboard"
@@ -51,30 +55,23 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
return (
<div className="min-h-screen bg-[#F9FAFB]">
{/* Top bar — brand-consistent: sharp, midnight, no blur */}
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white">
<div className="flex h-14 items-center gap-4 px-4 md:px-6">
<div className="min-h-screen bg-white">
{/* Top bar — midnight, sharp, editorial */}
<header className="sticky top-0 z-40 bg-[#111827]">
<div className="flex h-12 items-center gap-4 px-4 md:px-6">
<Link href="/dashboard" className="flex items-center gap-2.5">
<div className="h-7 w-7 bg-[#111827] flex items-center justify-center">
<span className="text-white text-xs font-black">P</span>
<div className="h-6 w-6 bg-white flex items-center justify-center">
<span className="text-[#111827] text-[10px] font-black">P</span>
</div>
<div className="hidden sm:block">
<span className="font-black text-sm text-[#111827] tracking-tight">{user?.orgName || "Pledge Now, Pay Later"}</span>
<span className="font-black text-sm text-white tracking-tight">{user?.orgName || "Pledge Now, Pay Later"}</span>
</div>
</Link>
<div className="flex-1" />
{!isCommunityLeader && (
<Link href="/dashboard/collect">
<button className="hidden md:inline-flex items-center gap-1.5 bg-[#111827] px-3.5 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
<Plus className="h-3 w-3" /> New Appeal
</button>
</Link>
)}
{session && (
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="text-xs text-gray-400 hover:text-[#111827] transition-colors flex items-center gap-1"
className="text-xs text-gray-500 hover:text-white transition-colors flex items-center gap-1"
aria-label="Sign out"
>
<LogOut className="h-3.5 w-3.5" />
@@ -84,8 +81,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</header>
<div className="flex">
{/* Desktop sidebar — brand style: sharp, left-border active state */}
<aside className="hidden md:flex w-48 flex-col border-r border-gray-200 bg-white min-h-[calc(100vh-3.5rem)] py-4 px-2">
{/* Desktop sidebar — editorial, quiet, border-l-2 active states */}
<aside className="hidden md:flex w-48 flex-col border-r border-gray-100 bg-white min-h-[calc(100vh-3rem)] py-6 px-2">
<nav className="space-y-0.5">
{navItems.map((item) => {
const active = isActive(item.href)
@@ -97,7 +94,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
active
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
: "text-gray-400 hover:bg-gray-50 hover:text-[#111827]"
)}
>
<item.icon className="h-4 w-4" />
@@ -114,7 +111,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
pathname === superAdminNav.href
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
: "text-gray-400 hover:bg-gray-50 hover:text-[#111827]"
)}
>
<superAdminNav.icon className="h-4 w-4" />
@@ -124,12 +121,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)}
</nav>
{/* Sidebar CTA — brand style, no emoji */}
<div className="mt-auto px-1.5 pt-4">
{/* Sidebar footer — brand style */}
<div className="mt-auto px-1.5 pt-6">
<div className="border-l-2 border-[#111827] pl-3 py-2">
<p className="text-xs font-bold text-[#111827]">Need expert help?</p>
<p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">
Get a fractional CTO for your charity&apos;s digital stack.
<p className="text-[10px] text-gray-400 leading-relaxed mt-0.5">
Fractional CTO for your charity&apos;s digital&nbsp;stack.
</p>
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-[#1E40AF] hover:underline mt-1">
Learn more
@@ -138,7 +135,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</div>
</aside>
{/* Mobile bottom nav — 5 items, icon + label */}
{/* Mobile bottom nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white flex justify-around py-1.5 px-1">
{navItems.map((item) => {
const active = isActive(item.href)
@@ -158,7 +155,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
})}
</nav>
{/* Main content */}
{/* Main content — white background, generous padding */}
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 md:pb-8 max-w-6xl">
<WhatsAppBanner />
{children}
@@ -168,7 +165,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)
}
/** Persistent WhatsApp connection banner — shows until connected */
/** WhatsApp connection banner — shows until connected */
function WhatsAppBanner() {
const [status, setStatus] = useState<string | null>(null)
const [dismissed, setDismissed] = useState(false)
@@ -190,7 +187,7 @@ function WhatsAppBanner() {
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-[#111827]">WhatsApp not connected reminders won&apos;t send</p>
<p className="text-xs text-gray-600 mt-0.5">
Connect your WhatsApp so donors automatically get payment reminders. Takes 60 seconds.
Connect your WhatsApp so donors automatically get payment reminders.
</p>
<div className="flex items-center gap-3 mt-2.5">
<Link

View File

@@ -3,21 +3,18 @@
import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import Image from "next/image"
import { formatPence } from "@/lib/utils"
import { ArrowRight, Loader2, MessageCircle, Copy, Check, Share2, Upload } from "lucide-react"
import Link from "next/link"
/**
* Context-aware dashboard.
* Context-aware dashboard — feels like a landing page for YOUR data.
*
* Instead of a static layout, this page morphs based on what the user
* needs RIGHT NOW. It mirrors their internal monologue:
*
* State 1 (no events): "I need to set up" → redirect to /welcome
* State 2 (0 pledges): "Did it work? How do I share?" → show link + share buttons
* State 3 (has pledges): "Who's pledged? Who's paid?" → show feed + stats
* State 4 (has overdue): "Who needs chasing?" → promote attention items
* State 5 (has "said paid"): "Did the money arrive?" → prompt bank upload
* Every state has:
* 1. A hero section (brand photography + dark panel with the key metric)
* 2. Content sections that use landing page patterns (gap-px, border-l-2)
* 3. Human language throughout
*/
const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }> = {
@@ -60,7 +57,6 @@ export default function DashboardPage() {
setLoading(false)
}, [])
// Redirect community leaders to their scoped dashboard
useEffect(() => {
if (userRole === "community_leader" || userRole === "volunteer") {
router.replace("/dashboard/community")
@@ -73,7 +69,6 @@ export default function DashboardPage() {
return () => clearInterval(i)
}, [fetchAll])
// Fetch the first QR code link
useEffect(() => {
if (events.length > 0) {
fetch(`/api/events/${events[0].id}/qr`)
@@ -88,12 +83,10 @@ export default function DashboardPage() {
}
}, [events])
// ── Loading ──
if (loading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
}
// ── State 1: No events → redirect to welcome ──
const hasEvents = events.length > 0
if (!hasEvents && ob) {
const eventDone = ob.steps?.find((s: { id: string; done: boolean }) => s.id === "event" || s.id === "share")?.done
@@ -109,6 +102,8 @@ export default function DashboardPage() {
const topSources = data?.topSources || []
const isEmpty = s.totalPledges === 0
const hasSaidPaid = (byStatus.initiated || 0) > 0
const allCollected = !isEmpty && s.collectionRate === 100
const outstanding = s.totalPledgedPence - s.totalCollectedPence
const recentPledges = pledges.filter((p: { status: string }) => p.status !== "cancelled").slice(0, 8)
const needsAttention = [
@@ -125,57 +120,112 @@ export default function DashboardPage() {
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-8">
// Pick contextual hero photo
const heroPhoto = allCollected
? "/images/brand/impact-02-thank-you-letter.jpg"
: isEmpty
? "/images/brand/product-setup-05-briefing-volunteers.jpg"
: "/images/brand/event-03-table-conversation.jpg"
{/* ── Context header: shows event name, not generic "Home" ── */}
<div className="flex items-center justify-between">
<div>
const heroAlt = allCollected
? "Woman reading a thank-you card — the quiet moment when every pledge gets through"
: isEmpty
? "Event organiser briefing volunteers before a fundraising dinner"
: "Friends at a charity dinner — where pledges begin"
return (
<div className="space-y-6">
{/* ━━ HERO — Brand photography + the one thing that matters ━━━ */}
<div className="grid md:grid-cols-5 gap-0">
<div className="md:col-span-3 relative min-h-[180px] md:min-h-[240px] overflow-hidden">
<Image
src={heroPhoto}
alt={heroAlt}
fill className="object-cover"
sizes="(max-width: 768px) 100vw, 60vw"
priority
/>
</div>
<div className={`md:col-span-2 p-6 md:p-8 flex flex-col justify-center ${allCollected ? "bg-[#16A34A]" : "bg-[#111827]"}`}>
{activeEvent && (
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
{events.length > 1 ? `${events.length} appeals` : "Your appeal"}
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
{activeEvent.name}
</p>
)}
<h1 className="text-3xl font-black text-[#111827] tracking-tight">
{activeEvent?.name || "Home"}
</h1>
{waConnected !== null && (
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
<MessageCircle className={`h-3 w-3 ${waConnected ? "text-[#25D366]" : "text-gray-400"}`} />
{waConnected ? "WhatsApp connected" : "WhatsApp not connected"}
</p>
{allCollected ? (
<>
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
Every pledge collected
</h1>
<p className="text-sm text-white/70 mt-2">
{formatPence(s.totalCollectedPence)} received from {s.totalPledges} donors
</p>
</>
) : isEmpty ? (
<>
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
Ready to collect pledges
</h1>
<p className="text-sm text-gray-400 mt-2">
Share your link pledges will appear here as they come in.
</p>
{waConnected !== null && (
<p className="text-xs text-gray-500 mt-3 flex items-center gap-1.5">
<MessageCircle className={`h-3 w-3 ${waConnected ? "text-[#25D366]" : "text-gray-500"}`} />
{waConnected ? "WhatsApp connected — reminders active" : "WhatsApp not connected"}
</p>
)}
</>
) : (
<>
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight">
{formatPence(outstanding)}
</h1>
<p className="text-sm text-gray-400 mt-1">
still to come · {s.collectionRate}% collected
</p>
{/* Contextual CTA — what should they do RIGHT NOW? */}
{hasSaidPaid ? (
<Link href="/dashboard/money" className="mt-4 inline-flex items-center gap-2 bg-white px-4 py-2.5 text-xs font-bold text-[#111827] hover:bg-gray-100 transition-colors self-start">
<Upload className="h-3.5 w-3.5" /> Upload bank statement
</Link>
) : (byStatus.overdue || 0) > 0 ? (
<Link href="/dashboard/money" className="mt-4 inline-flex items-center gap-2 border border-gray-600 px-4 py-2.5 text-xs font-bold text-gray-300 hover:text-white hover:border-white transition-colors self-start">
{byStatus.overdue} {byStatus.overdue === 1 ? "pledge needs" : "pledges need"} a nudge <ArrowRight className="h-3 w-3" />
</Link>
) : null}
</>
)}
</div>
{events.length > 1 && (
<Link href="/dashboard/collect" className="text-xs font-semibold text-[#1E40AF] hover:underline">
Switch appeal
</Link>
)}
</div>
{/* ── State 2: Has event, no pledges → "Share your link" ── */}
{/* ── Empty state: Share your link ── */}
{isEmpty && pledgeLink && (
<div className="space-y-6">
<div className="bg-[#111827] p-6 space-y-4">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Your pledge link</p>
<p className="text-white text-sm font-mono break-all">{pledgeLink}</p>
<div className="grid grid-cols-3 gap-2">
<button onClick={copyLink} className="bg-white/10 hover:bg-white/20 py-2.5 text-xs font-bold text-white transition-colors flex items-center justify-center gap-1.5">
<div className="border-l-2 border-[#F59E0B] pl-3">
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Your pledge link</p>
</div>
<div className="bg-white border border-gray-200 p-5 space-y-4">
<p className="text-sm font-mono text-gray-500 break-all">{pledgeLink}</p>
<div className="grid grid-cols-3 gap-1.5">
<button onClick={copyLink} className={`py-2.5 text-xs font-bold transition-colors flex items-center justify-center gap-1.5 ${
copied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"
}`}>
{copied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Copy className="h-3.5 w-3.5" /> Copy</>}
</button>
<button onClick={() => window.open(`https://wa.me/?text=${encodeURIComponent(`Please pledge for ${activeEvent?.name}:\n${pledgeLink}`)}`, "_blank")} className="bg-[#25D366] hover:bg-[#25D366]/90 py-2.5 text-xs font-bold text-white transition-colors flex items-center justify-center gap-1.5">
<MessageCircle className="h-3.5 w-3.5" /> WhatsApp
</button>
<button onClick={() => navigator.share?.({ url: pledgeLink, title: activeEvent?.name })} className="bg-white/10 hover:bg-white/20 py-2.5 text-xs font-bold text-white transition-colors flex items-center justify-center gap-1.5">
<button onClick={() => navigator.share?.({ url: pledgeLink, title: activeEvent?.name })} className="border border-gray-200 hover:bg-gray-50 py-2.5 text-xs font-bold text-[#111827] transition-colors flex items-center justify-center gap-1.5">
<Share2 className="h-3.5 w-3.5" /> Share
</button>
</div>
</div>
<div className="text-center py-6">
<p className="text-sm text-gray-500">No pledges yet share your link and they&apos;ll appear here</p>
</div>
<div className="border-l-2 border-[#1E40AF] pl-4">
<Link href={`/dashboard/events/${activeEvent?.id}`} className="text-sm font-bold text-[#111827] hover:text-[#1E40AF] transition-colors">
Create more pledge links
@@ -185,10 +235,10 @@ export default function DashboardPage() {
</div>
)}
{/* ── State 3+: Has pledges → stats + feed ── */}
{/* ── Has pledges: Stats + Feed ── */}
{!isEmpty && (
<>
{/* Big numbers */}
{/* Stats — dark inversion like landing page hero */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
{[
{ value: String(s.totalPledges), label: "Pledges" },
@@ -214,13 +264,13 @@ export default function DashboardPage() {
</div>
<div className="flex justify-between mt-2 text-xs text-gray-500">
<span>{formatPence(s.totalCollectedPence)} received</span>
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} still to come</span>
<span>{formatPence(outstanding)} still to come</span>
</div>
</div>
{/* ── Contextual prompt: "said they paid" → upload bank statement ── */}
{/* ── "Said they paid" prompt ── */}
{hasSaidPaid && (
<Link href="/dashboard/reconcile">
<Link href="/dashboard/money">
<div className="border-l-2 border-[#F59E0B] bg-[#F59E0B]/5 p-4 flex items-center gap-3 hover:bg-[#F59E0B]/10 transition-colors cursor-pointer">
<Upload className="h-5 w-5 text-[#F59E0B] shrink-0" />
<div className="flex-1">
@@ -237,7 +287,7 @@ export default function DashboardPage() {
<div className="lg:col-span-2 space-y-6">
{/* Needs attention */}
{needsAttention.length > 0 && (
<div className="bg-white">
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-[#111827]">Needs attention</h3>
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
@@ -263,7 +313,7 @@ export default function DashboardPage() {
)}
{/* How pledges are doing */}
<div className="bg-white">
<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]">How pledges are doing</h3>
</div>
@@ -280,9 +330,9 @@ export default function DashboardPage() {
</div>
</div>
{/* Top sources */}
{/* Where pledges come from */}
{topSources.length > 0 && (
<div className="bg-white">
<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]">Where pledges come from</h3>
</div>
@@ -303,7 +353,7 @@ export default function DashboardPage() {
{/* RIGHT column: Recent pledges */}
<div className="lg:col-span-3">
<div className="bg-white">
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-[#111827]">Recent pledges</h3>
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">View all</Link>
@@ -349,14 +399,6 @@ export default function DashboardPage() {
</div>
</>
)}
{/* ── State: all paid → celebration ── */}
{!isEmpty && s.collectionRate === 100 && (
<div className="bg-[#16A34A]/5 border border-[#16A34A]/20 p-6 text-center">
<p className="text-2xl font-black text-[#16A34A]">Every pledge collected</p>
<p className="text-sm text-gray-600 mt-1">{formatPence(s.totalCollectedPence)} received from {s.totalPledges} donors</p>
</div>
)}
</div>
)
}

View File

@@ -8,6 +8,7 @@ import {
ChevronLeft, ChevronRight, Loader2, Upload, AlertTriangle,
Clock, HelpCircle, AlertCircle, FileUp, X
} from "lucide-react"
import Image from "next/image"
import { formatPence } from "@/lib/utils"
/**
@@ -259,12 +260,28 @@ export default function MoneyPage() {
return (
<div className="space-y-6">
{/* ── Header ── */}
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
<p className="text-sm text-gray-500 mt-0.5">
{formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised
</p>
{/* ━━ HERO — Dark stats panel like landing page ━━━━━━━━━━━━ */}
<div className="grid md:grid-cols-5 gap-0 mb-6">
<div className="md:col-span-2 relative min-h-[140px] md:min-h-[180px] overflow-hidden">
<Image
src="/images/brand/ops-06-counting-money.jpg"
alt="Charity treasurer counting donations after an event"
fill className="object-cover"
sizes="(max-width: 768px) 100vw, 40vw"
/>
</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">Money</p>
</div>
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight">
{formatPence(stats.totalCollected)}
<span className="text-gray-500 text-xl md:text-2xl ml-2">of {formatPence(stats.totalPledged)}</span>
</h1>
<p className="text-sm text-gray-400 mt-1">
{collectionRate}% collected · {stats.total} pledges
</p>
</div>
</div>
{/* ── Stats bar (clickable filters) ── */}

View File

@@ -188,24 +188,24 @@ export default function SettingsPage() {
return (
<div className="space-y-6 max-w-2xl">
{/* ── Header ── */}
<div>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">{settings.name}</p>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Settings</h1>
</div>
{/* ── Progress — human sentence, not a grid ── */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1 h-1.5 bg-gray-100 overflow-hidden">
{/* ── Header — human progress, not a form page ── */}
<div className={`p-6 mb-6 ${doneCount === totalCount ? "bg-[#16A34A]" : "bg-[#111827]"}`}>
<div className="border-l-2 border-[#F59E0B] pl-3 mb-3">
<p className="text-[11px] font-semibold tracking-[0.15em] uppercase text-gray-500">Settings</p>
</div>
<h1 className="text-2xl md:text-3xl font-black text-white tracking-tight">
{settings.name}
</h1>
<p className="text-sm text-gray-400 mt-2">{headerMsg}</p>
<div className="flex items-center gap-3 mt-4">
<div className="flex-1 h-1.5 bg-white/10 overflow-hidden">
<div
className={`h-full transition-all duration-700 ${doneCount === totalCount ? "bg-[#16A34A]" : "bg-[#1E40AF]"}`}
className={`h-full transition-all duration-700 ${doneCount === totalCount ? "bg-white" : "bg-[#1E40AF]"}`}
style={{ width: `${Math.round((doneCount / totalCount) * 100)}%` }}
/>
</div>
<span className="text-xs font-bold text-[#111827] shrink-0">{doneCount}/{totalCount}</span>
<span className="text-xs font-bold text-white shrink-0">{doneCount}/{totalCount}</span>
</div>
<p className="text-sm text-gray-500">{headerMsg}</p>
</div>
{error && <div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>}