Complete dashboard UI overhaul: persona journeys + brand unification

Navigation: goal-oriented, not feature-oriented
- Overview → Home
- Campaigns → Collect ('I want people to pledge')
- Pledges → Money ('Where's the money?')
- Exports → Reports ('My treasurer needs numbers')
- Old routes still work via re-exports

Terminology: human language, not SaaS jargon
- new → Waiting
- initiated → Said they paid
- paid → Received ✓
- overdue → Needs a nudge
- Campaign → Appeal
- QR Source → Pledge link
- Reconcile → Match payments
- Rail → Payment method
- Pipeline by Status → How pledges are doing
- Conversion rate → % who pledged
- CRM Export Pack → Full data download

Visual identity: brand-consistent dashboard
- Sharp edges (no rounded-lg cards)
- Gap-px grids for stats (brand signature pattern)
- Left-border accents (brand signature pattern)
- Midnight/Paper/Promise Blue 60-30-10 color rule
- Typography as hero (big bold numbers, not card-heavy)
- No emoji in UI chrome
- Brand-consistent status badges (colored bg + text, not shadcn Badge)
- Consistent header typography (text-3xl font-black tracking-tight)

Pages rewritten: layout, home, events (collect), pledges (money),
exports (reports), reconcile, settings

Reconcile: auto-detects bank CSV format via presets + AI before upload

UX spec: docs/UX_OVERHAUL_SPEC.md
This commit is contained in:
2026-03-04 20:50:42 +08:00
parent fcfae1c1a4
commit 170a2e7c68
13 changed files with 1867 additions and 1399 deletions

View File

@@ -1,134 +1,100 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { formatPence } from "@/lib/utils"
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle, X, Building2, Heart } from "lucide-react"
import { ArrowRight, Loader2, MessageCircle, CheckCircle2, Circle, X } from "lucide-react"
import Link from "next/link"
interface DashboardData {
summary: { totalPledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number; overdueRate: number }
byStatus: Record<string, number>
byRail: Record<string, number>
topSources: Array<{ label: string; count: number; amount: number }>
pledges: Array<{
id: string; reference: string; amountPence: number; status: string; rail: string;
donorName: string | null; donorEmail: string | null; donorPhone: string | null;
eventName: string; source: string | null; giftAid: boolean;
dueDate: string | null; isDeferred: boolean; planId: string | null;
installmentNumber: number | null; installmentTotal: number | null;
createdAt: string; paidAt: string | null; nextReminder: string | null;
}>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DashboardData = any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type OnboardingData = any
/**
* Human-readable status labels.
* These replace SaaS jargon with language a charity volunteer would use.
*/
const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }> = {
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
paid: { label: "Received", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
}
interface OnboardingData {
steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string; action?: string }>
completed: number
total: number
allDone: boolean
orgType: string | null
needsRole: boolean
orgName: string
}
// ─── Getting Started ─────────────────────────────────────────
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
// ─── Role Picker ────────────────────────────────────────────
function RolePicker({ onSelect }: { onSelect: (role: string) => void }) {
return (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => onSelect("charity")}
className="rounded-lg border-2 border-gray-100 hover:border-trust-blue bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
>
<div className="mx-auto w-12 h-12 rounded-lg bg-trust-blue/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<Building2 className="h-6 w-6 text-trust-blue" />
</div>
<p className="text-sm font-bold text-gray-900">Charity / Mosque</p>
<p className="text-[11px] text-muted-foreground leading-tight">We collect donations directly via bank transfer</p>
</button>
<button
onClick={() => onSelect("fundraiser")}
className="rounded-lg border-2 border-gray-100 hover:border-warm-amber bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
>
<div className="mx-auto w-12 h-12 rounded-lg bg-warm-amber/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<Heart className="h-6 w-6 text-warm-amber" />
</div>
<p className="text-sm font-bold text-gray-900">Personal Fundraiser</p>
<p className="text-[11px] text-muted-foreground leading-tight">I have a page on LaunchGood, Enthuse, JustGiving, etc.</p>
</button>
</div>
)
}
// ─── Getting Started Banner ─────────────────────────────────
function GettingStartedBanner({
ob,
onSetRole,
dismissed,
onDismiss,
function GettingStarted({
ob, onSetRole, dismissed, onDismiss,
}: {
ob: OnboardingData
onSetRole: (role: string) => void
dismissed: boolean
onDismiss: () => void
ob: OnboardingData; onSetRole: (role: string) => void; dismissed: boolean; onDismiss: () => void
}) {
const [showRolePicker, setShowRolePicker] = useState(!ob.orgType || ob.orgType === "charity")
if (ob.allDone || dismissed) return null
// First-time: show role picker
const isFirstTime = ob.completed === 0 && (!ob.orgType || ob.orgType === "charity")
const isFirstTime = ob.completed === 0
return (
<div className="rounded-lg border border-trust-blue/20 bg-paper p-5 space-y-4 relative">
{/* Dismiss X */}
<button onClick={onDismiss} className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1">
<div className="border-l-2 border-[#1E40AF] bg-white p-5 relative">
<button onClick={onDismiss} className="absolute top-3 right-3 text-gray-300 hover:text-gray-600 p-1">
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-midnight flex items-center justify-center flex-shrink-0">
<span className="text-white text-lg">🤲</span>
<div className="flex items-center gap-3 mb-4">
<div className="h-8 w-8 bg-[#111827] flex items-center justify-center">
<span className="text-white text-xs font-black">P</span>
</div>
<div>
<h2 className="text-sm font-bold text-gray-900">
{isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`}
<h2 className="text-sm font-black text-[#111827]">
{isFirstTime ? "Let's get you set up" : `Getting started ${ob.completed} of ${ob.total} done`}
</h2>
{!isFirstTime && (
<Progress value={(ob.completed / ob.total) * 100} className="h-1.5 mt-1.5 w-32" indicatorClassName="bg-promise-blue" />
<div className="flex gap-1 mt-1.5">
{ob.steps.map((step: { id: string; done: boolean }) => (
<div key={step.id} className={`h-1 w-8 ${step.done ? "bg-[#1E40AF]" : "bg-gray-200"}`} />
))}
</div>
)}
</div>
</div>
{isFirstTime && showRolePicker ? (
<RolePicker onSelect={(role) => { onSetRole(role); setShowRolePicker(false) }} />
{isFirstTime && !ob.orgType ? (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => onSetRole("charity")}
className="border-2 border-gray-100 hover:border-[#1E40AF] bg-white p-4 text-left transition-all group"
>
<p className="text-sm font-bold text-[#111827]">Charity or Mosque</p>
<p className="text-[11px] text-gray-500 mt-1">We collect donations via bank transfer</p>
</button>
<button
onClick={() => onSetRole("fundraiser")}
className="border-2 border-gray-100 hover:border-[#F59E0B] bg-white p-4 text-left transition-all group"
>
<p className="text-sm font-bold text-[#111827]">Personal Fundraiser</p>
<p className="text-[11px] text-gray-500 mt-1">I use LaunchGood, JustGiving, etc.</p>
</button>
</div>
) : (
<div className="space-y-1.5">
{ob.steps.map((step, i) => {
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
<div className="space-y-1">
{ob.steps.map((step: { id: string; label: string; done: boolean; href: string }, i: number) => {
const isNext = !step.done && ob.steps.slice(0, i).every((s: { done: boolean }) => s.done)
return (
<Link key={step.id} href={step.href}>
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2.5 transition-all ${
step.done ? "bg-success-green/5 border-success-green/20" :
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
"bg-white border-gray-100"
<div className={`flex items-center gap-2.5 px-3 py-2.5 transition-all ${
step.done ? "opacity-50" :
isNext ? "bg-[#1E40AF]/5 border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]" :
""
}`}>
{step.done ? (
<CheckCircle2 className="h-4 w-4 text-success-green flex-shrink-0" />
<CheckCircle2 className="h-4 w-4 text-[#16A34A] shrink-0" />
) : isNext ? (
<div className="h-4 w-4 rounded-full bg-trust-blue text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
<div className="h-4 w-4 bg-[#1E40AF] text-white text-[10px] font-bold flex items-center justify-center shrink-0">{i + 1}</div>
) : (
<Circle className="h-4 w-4 text-gray-300 flex-shrink-0" />
<Circle className="h-4 w-4 text-gray-300 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium truncate ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
</div>
{isNext && <ArrowRight className="h-3 w-3 text-trust-blue flex-shrink-0" />}
<span className={`text-xs font-medium ${step.done ? "line-through text-gray-400" : isNext ? "text-[#111827]" : "text-gray-400"}`}>
{step.label}
</span>
{isNext && <ArrowRight className="h-3 w-3 text-[#1E40AF] ml-auto shrink-0" />}
</div>
</Link>
)
@@ -140,6 +106,7 @@ function GettingStartedBanner({
}
// ─── Main Dashboard ─────────────────────────────────────────
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
@@ -169,7 +136,6 @@ export default function DashboardPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orgType: role }),
})
// Refresh onboarding state
const res = await fetch("/api/onboarding")
const d = await res.json()
if (d.steps) setOb(d)
@@ -178,291 +144,227 @@ export default function DashboardPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
<Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" />
</div>
)
}
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 }
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
const byStatus = data?.byStatus || {}
const topSources = data?.topSources || []
const pledges = data?.pledges || []
const upcomingPledges = pledges.filter(p =>
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
const recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8)
const needsAction = [
...pledges.filter(p => p.status === "overdue"),
...upcomingPledges.filter(p => {
const due = new Date(p.dueDate!)
return due.getTime() - Date.now() < 2 * 86400000
})
const recentPledges = pledges.filter((p: { status: string }) => p.status !== "cancelled").slice(0, 8)
const needsAttention = [
...pledges.filter((p: { status: string }) => p.status === "overdue"),
...pledges.filter((p: { status: string; dueDate: string | null }) =>
p.status !== "paid" && p.status !== "cancelled" && p.dueDate &&
new Date(p.dueDate).getTime() - Date.now() < 2 * 86400000
),
].slice(0, 5)
const isEmpty = s.totalPledges === 0
return (
<div className="space-y-6">
{/* Getting-started banner — always at top, not a blocker */}
<div className="space-y-8">
{/* Onboarding */}
{ob && !ob.allDone && (
<GettingStartedBanner ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
<GettingStarted ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
)}
{/* Page header — brand typography */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-0.5">
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Home</h1>
<p className="text-sm text-gray-500 mt-0.5">
{whatsappStatus !== null && (
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-muted-foreground"}`}>
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-gray-400"}`}>
<MessageCircle className="h-3 w-3" />
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
</span>
)}
{isEmpty ? "Your data will appear here" : "Auto-refreshes every 15s"}
{isEmpty ? "Your numbers will appear here once donors start pledging" : "Updates every 15 seconds"}
</p>
</div>
{!isEmpty && (
<Link href="/dashboard/pledges">
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
)}
</div>
{/* Stats — always show, even with zeros */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
<div>
<p className="text-2xl font-black">{s.totalPledges}</p>
<p className="text-xs text-muted-foreground">Total Pledges</p>
</div>
</div>
</CardContent>
</Card>
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
<div>
<p className="text-2xl font-black">{formatPence(s.totalPledgedPence)}</p>
<p className="text-xs text-muted-foreground">Total Pledged</p>
</div>
</div>
</CardContent>
</Card>
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
<div>
<p className="text-2xl font-black">{formatPence(s.totalCollectedPence)}</p>
<p className="text-xs text-muted-foreground">Collected ({s.collectionRate}%)</p>
</div>
</div>
</CardContent>
</Card>
<Card className={isEmpty ? "opacity-60" : s.overdueRate > 10 ? "border-danger-red/30" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
<div>
<p className="text-2xl font-black">{byStatus.overdue || 0}</p>
<p className="text-xs text-muted-foreground">Overdue</p>
</div>
</div>
</CardContent>
</Card>
{/* ─── Big Numbers — gap-px grid (brand pattern) ─── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
{[
{ value: String(s.totalPledges), label: "Pledges", sub: isEmpty ? "—" : undefined },
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: true },
{ value: `${s.collectionRate}%`, label: "Collected" },
].map((stat) => (
<div key={stat.label} className={`bg-white p-5 ${isEmpty ? "opacity-50" : ""}`}>
<p className={`text-2xl md:text-3xl font-black tracking-tight ${stat.accent ? "text-[#16A34A]" : "text-[#111827]"}`}>
{stat.value}
</p>
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
</div>
))}
</div>
{/* Collection progress — always visible */}
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Pledged Collected</span>
<span className="text-sm font-bold text-muted-foreground">{s.collectionRate}%</span>
{/* ─── Collection Progress — brand bar ─── */}
{!isEmpty && (
<div className="bg-white p-5">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-bold text-[#111827]">Promised Received</span>
<span className="text-sm font-black text-[#111827]">{s.collectionRate}%</span>
</div>
<Progress value={s.collectionRate} indicatorClassName="bg-promise-blue" />
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span>{formatPence(s.totalCollectedPence)} collected</span>
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} outstanding</span>
<div className="h-3 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
</div>
</CardContent>
</Card>
<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>
</div>
</div>
)}
{isEmpty ? (
/* Empty state — gentle nudge, not a blocker */
<Card className="border-dashed">
<CardContent className="py-10 text-center space-y-3">
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto" />
<h3 className="text-sm font-bold text-gray-900">Your pledge data will appear here</h3>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
Once you share your first link and donors start pledging, you&apos;ll see live stats, payment tracking, and reminders.
</p>
<div className="flex gap-2 justify-center pt-2">
<Link href="/dashboard/events">
<Button size="sm">Create a Campaign </Button>
</Link>
</div>
</CardContent>
</Card>
/* Empty state — clean, directive */
<div className="bg-white border-2 border-dashed border-gray-200 p-10 text-center">
<p className="text-4xl font-black text-gray-200 mb-3">01</p>
<h3 className="text-base font-bold text-[#111827]">Share your first pledge link</h3>
<p className="text-sm text-gray-500 mt-1 max-w-sm mx-auto">
Create an appeal, share the link with donors, and watch pledges come in here.
</p>
<Link href="/dashboard/collect">
<button className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors">
Create an Appeal
</button>
</Link>
</div>
) : (
<>
<div className="grid lg:grid-cols-2 gap-4">
<div className="grid lg:grid-cols-5 gap-6">
{/* LEFT: Needs attention + Pipeline */}
<div className="lg:col-span-2 space-y-6">
{/* Needs attention */}
<Card className={needsAction.length > 0 ? "border-warm-amber/30" : ""}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warm-amber" /> Needs Attention
{needsAction.length > 0 && <Badge variant="warning">{needsAction.length}</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{needsAction.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">All clear! No urgent items.</p>
) : (
needsAction.map(p => (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{formatPence(p.amountPence)} · {p.eventName}
{p.dueDate && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
</p>
{needsAttention.length > 0 && (
<div className="bg-white">
<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>
</div>
<div className="divide-y divide-gray-50">
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; dueDate: string | null }) => {
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
return (
<div key={p.id} className="px-5 py-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[#111827]">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-gray-500">{formatPence(p.amountPence)} · {p.eventName}</p>
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div>
<Badge variant={p.status === "overdue" ? "destructive" : "warning"}>
{p.status === "overdue" ? "Overdue" : "Due soon"}
</Badge>
</div>
))
)}
{needsAction.length > 0 && (
<Link href="/dashboard/pledges?tab=overdue" className="text-xs text-trust-blue hover:underline flex items-center gap-1 pt-1">
View all <ArrowRight className="h-3 w-3" />
)
})}
</div>
<div className="px-5 py-2 border-t border-gray-50">
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">
View all
</Link>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Upcoming payments */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4 text-trust-blue" /> Upcoming Payments
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{upcomingPledges.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No scheduled payments</p>
) : (
upcomingPledges.slice(0, 5).map(p => (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-trust-blue/5 flex items-center justify-center text-xs font-bold text-trust-blue">
{new Date(p.dueDate!).getDate()}
<br />
<span className="text-[8px]">{new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })}</span>
</div>
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{p.eventName}
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
</p>
</div>
</div>
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Pipeline + Sources */}
<div className="grid lg:grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Pipeline by Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* How pledges are doing — gap-px grid */}
<div className="bg-white">
<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>
<div className="divide-y divide-gray-50">
{Object.entries(byStatus).map(([status, count]) => {
const Icon = statusIcons[status] || Clock
const sl = STATUS_LABELS[status] || STATUS_LABELS.new
return (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<Badge variant={statusColors[status] || "secondary"}>{status}</Badge>
</div>
<span className="font-bold">{count}</span>
</div>
)
})}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Top Sources</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{topSources.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
) : (
topSources.slice(0, 6).map((src, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
<span className="text-sm">{src.label}</span>
<span className="text-xs text-muted-foreground">{src.count} pledges</span>
</div>
<span className="font-bold text-sm">{formatPence(src.amount)}</span>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Recent pledges */}
<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<CardTitle className="text-base">Recent Pledges</CardTitle>
<Link href="/dashboard/pledges">
<Button variant="ghost" size="sm" className="text-xs">View all <ExternalLink className="h-3 w-3 ml-1" /></Button>
</Link>
</CardHeader>
<CardContent>
<div className="space-y-2">
{recentPledges.map(p => {
const sc = statusColors[p.status] || "secondary"
return (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
{(p.donorName || "A")[0].toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
{p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
<Badge variant={sc}>{p.status}</Badge>
</div>
<div key={status} className="px-5 py-2.5 flex items-center justify-between">
<span className={`text-xs font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
<span className="text-sm font-black text-[#111827]">{count as number}</span>
</div>
)
})}
</div>
</CardContent>
</Card>
</>
</div>
{/* Top sources */}
{topSources.length > 0 && (
<div className="bg-white">
<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>
<div className="divide-y divide-gray-50">
{topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => (
<div key={i} className="px-5 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="text-xs font-black text-gray-300 w-4">{i + 1}</span>
<span className="text-sm text-[#111827]">{src.label}</span>
<span className="text-[10px] text-gray-400">{src.count} pledges</span>
</div>
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* RIGHT: Recent pledges */}
<div className="lg:col-span-3">
<div className="bg-white">
<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>
</div>
<div className="divide-y divide-gray-50">
{recentPledges.map((p: {
id: string; donorName: string | null; amountPence: number; status: string;
eventName: string; createdAt: string; donorPhone: string | null;
installmentNumber: number | null; installmentTotal: number | null;
}) => {
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
const initial = (p.donorName || "A")[0].toUpperCase()
const daysDiff = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
const timeLabel = daysDiff === 0 ? "Today" : daysDiff === 1 ? "Yesterday" : daysDiff < 7 ? `${daysDiff}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
return (
<div key={p.id} className="px-5 py-3 flex items-center gap-3">
<div className="h-8 w-8 bg-[#1E40AF]/10 flex items-center justify-center shrink-0">
<span className="text-xs font-black text-[#1E40AF]">{initial}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
{p.donorPhone && <MessageCircle className="h-3 w-3 text-[#25D366] shrink-0" />}
</div>
<p className="text-xs text-gray-500 truncate">
{p.eventName} · {timeLabel}
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
<span className={`text-[10px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div>
</div>
)
})}
{recentPledges.length === 0 && (
<div className="px-5 py-8 text-center text-sm text-gray-400">
Pledges will appear here as they come in
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
)