Files
calvana/pledge-now-pay-later/src/app/dashboard/page.tsx
Omair Saleh dc8e593849 Fix dead space: remove max-w-6xl, edge-to-edge heroes, full-width layout
Layout:
- Removed max-w-6xl from <main> — content now fills available width
- Removed padding from <main> — each page manages its own padding
- Heroes go edge-to-edge (no inner margins)
- Content below heroes has p-4 md:p-6 lg:p-8 padding wrapper
- WhatsApp banner has its own margin so it doesn't break hero bleed
- overflow-hidden on main prevents horizontal scroll from heroes

All 6 pages:
- Hero section sits flush against edges (no gaps)
- Content below hero wrapped in padding container
- Two-column grids now use the FULL available width
- On a 1920px screen: sidebar 192px + content fills remaining ~1728px
- Right columns are now substantial (5/12 of full width = ~720px)
2026-03-05 03:49:02 +08:00

498 lines
25 KiB
TypeScript

"use client"
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 — feels like a landing page for YOUR data.
*
* 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 }> = {
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" },
}
export default function DashboardPage() {
const router = useRouter()
const { data: session } = useSession()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userRole = (session?.user as any)?.role
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [data, setData] = useState<any>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [ob, setOb] = useState<any>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [events, setEvents] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [waConnected, setWaConnected] = useState<boolean | null>(null)
const [pledgeLink, setPledgeLink] = useState("")
const [copied, setCopied] = useState(false)
const fetchAll = useCallback(async () => {
try {
const [dashRes, obRes, evRes, waRes] = await Promise.all([
fetch("/api/dashboard").then(r => r.json()),
fetch("/api/onboarding").then(r => r.json()),
fetch("/api/events").then(r => r.json()),
fetch("/api/whatsapp/send").then(r => r.json()).catch(() => ({ connected: false })),
])
if (dashRes.summary) setData(dashRes)
if (obRes.steps) setOb(obRes)
if (Array.isArray(evRes)) setEvents(evRes)
setWaConnected(waRes.connected)
} catch { /* */ }
setLoading(false)
}, [])
useEffect(() => {
if (userRole === "community_leader" || userRole === "volunteer") {
router.replace("/dashboard/community")
}
}, [userRole, router])
useEffect(() => {
fetchAll()
const i = setInterval(fetchAll, 15000)
return () => clearInterval(i)
}, [fetchAll])
useEffect(() => {
if (events.length > 0) {
fetch(`/api/events/${events[0].id}/qr`)
.then(r => r.json())
.then(qrs => {
if (Array.isArray(qrs) && qrs.length > 0) {
const base = typeof window !== "undefined" ? window.location.origin : ""
setPledgeLink(`${base}/p/${qrs[0].code}`)
}
})
.catch(() => {})
}
}, [events])
if (loading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
}
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
if (!eventDone) {
router.replace("/dashboard/welcome")
return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
}
}
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
const byStatus = data?.byStatus || {}
const pledges = data?.pledges || []
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 = [
...pledges.filter((p: { status: string }) => p.status === "overdue"),
...pledges.filter((p: { status: string }) => p.status === "initiated"),
].slice(0, 5)
const activeEvent = events[0]
const copyLink = async () => {
if (!pledgeLink) return
await navigator.clipboard.writeText(pledgeLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
// 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"
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>
{/* ━━ 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-3">
{activeEvent.name}
</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>
</div>
{/* ── Content with padding ── */}
<div className="p-4 md:p-6 lg:p-8 space-y-6">
{/* ── Empty state: Share your link ── */}
{isEmpty && pledgeLink && (
<div className="space-y-6">
<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="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="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
</Link>
<p className="text-xs text-gray-500 mt-0.5">Give each volunteer or table their own link to see who brings in the most pledges</p>
</div>
</div>
)}
{/* ── Empty state: educational guidance ── */}
{isEmpty && !pledgeLink && (
<div className="grid md:grid-cols-2 gap-6">
<div className="border-2 border-dashed border-gray-200 p-8 text-center">
<h3 className="text-base font-bold text-[#111827]">Share your pledge link to get started</h3>
<p className="text-sm text-gray-500 mt-2 max-w-xs mx-auto">
Go to <Link href="/dashboard/collect" className="text-[#1E40AF] font-bold hover:underline">Collect</Link> to
create an appeal and get your pledge link.
</p>
</div>
<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 className="divide-y divide-gray-50">
{[
{ n: "01", title: "Create a pledge link", desc: "Give it a name like 'Table 5' or 'Ramadan 2026'. Print the QR or share the link." },
{ n: "02", title: "Donors pledge in 60 seconds", desc: "Name, phone, amount, Gift Aid — done. No account, no app download." },
{ n: "03", title: "They get your bank details", desc: "Instantly via WhatsApp. With a unique reference so you can match their payment." },
{ n: "04", title: "Reminders go out automatically", desc: "Day 2, day 7, day 14. Warm and never pushy. They stop when the donor pays." },
{ n: "05", title: "Upload your bank statement", desc: "We match payments to pledges automatically. No spreadsheet cross-referencing." },
].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>
</div>
))}
</div>
</div>
</div>
)}
{/* ── Has pledges: Stats + Feed + Education ── */}
{!isEmpty && (
<>
{/* Stats — gap-px grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
{[
{ value: String(s.totalPledges), label: "Pledges" },
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: "text-[#16A34A]" },
{ value: `${s.collectionRate}%`, label: "Collected" },
].map(stat => (
<div key={stat.label} className="bg-white p-5">
<p className={`text-2xl md:text-3xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
<p className="text-[10px] text-gray-500 mt-1">{stat.label}</p>
</div>
))}
</div>
{/* Progress bar */}
<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>
<div className="h-3 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
</div>
<div className="flex justify-between mt-2 text-xs text-gray-500">
<span>{formatPence(s.totalCollectedPence)} received</span>
<span>{formatPence(outstanding)} still to come</span>
</div>
</div>
{/* ── "Said they paid" prompt ── */}
{hasSaidPaid && (
<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">
<p className="text-sm font-bold text-[#111827]">{byStatus.initiated} {byStatus.initiated === 1 ? "person says" : "people say"} they&apos;ve paid</p>
<p className="text-xs text-gray-600">Upload your bank statement to confirm their payments automatically</p>
</div>
<ArrowRight className="h-4 w-4 text-[#F59E0B] shrink-0" />
</div>
</Link>
)}
<div className="grid lg:grid-cols-12 gap-6">
{/* LEFT column: Data */}
<div className="lg:col-span-7 space-y-6">
{/* Needs attention */}
{needsAttention.length > 0 && (
<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>
</div>
<div className="divide-y divide-gray-50">
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string }) => {
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>
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div>
)
})}
</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>
</div>
</div>
)}
{/* Recent pledges */}
<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>
</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 days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : days < 7 ? `${days}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">{when}</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>
{/* RIGHT column: Status + Sources + Guidance */}
<div className="lg:col-span-5 space-y-6">
{/* How pledges are doing */}
<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>
<div className="divide-y divide-gray-50">
{Object.entries(byStatus).map(([status, count]) => {
const sl = STATUS_LABELS[status] || STATUS_LABELS.new
return (
<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>
</div>
{/* Where pledges come from */}
{topSources.length > 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]">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>
</div>
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
</div>
))}
</div>
</div>
)}
{/* What to do next — contextual guidance */}
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-2">
<p className="text-xs font-bold text-[#111827]">What to do next</p>
<div className="space-y-2">
{s.collectionRate < 100 && (byStatus.initiated || 0) > 0 && (
<Link href="/dashboard/money" className="flex items-start gap-2 group">
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5"></span>
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
<strong>Upload your bank statement</strong> to confirm {byStatus.initiated} {byStatus.initiated === 1 ? "payment" : "payments"} automatically
</p>
</Link>
)}
{s.collectionRate < 50 && (
<Link href="/dashboard/collect" className="flex items-start gap-2 group">
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5"></span>
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
<strong>Share your link more widely</strong> WhatsApp groups, social media, or print the QR
</p>
</Link>
)}
<Link href="/dashboard/automations" className="flex items-start gap-2 group">
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5"></span>
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
<strong>Check your messages</strong> see what donors receive and improve wording
</p>
</Link>
<Link href="/dashboard/reports" className="flex items-start gap-2 group">
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5"></span>
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
<strong>Download for your treasurer</strong> Gift Aid report, full pledge data, HMRC-ready CSV
</p>
</Link>
</div>
</div>
{/* Understanding statuses */}
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-1.5">
<p className="text-xs font-bold text-[#111827]">What the statuses mean</p>
{[
{ label: "Waiting", desc: "Pledged but hasn't paid yet — reminders are being sent" },
{ label: "Said they paid", desc: "Donor replied PAID — upload bank statement to confirm" },
{ label: "Received ✓", desc: "Payment confirmed in your bank account" },
{ label: "Needs a nudge", desc: "It's been a while — you can send a manual reminder" },
].map(s => (
<div key={s.label} className="flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[#1E40AF] shrink-0 mt-1.5" />
<p className="text-[10px] text-gray-500"><strong className="text-[#111827]">{s.label}</strong> {s.desc}</p>
</div>
))}
</div>
</div>
</div>
</>
)}
</div>
</div>
)
}