Telepathic onboarding: welcome flow + context-aware dashboard

New: /dashboard/welcome — guided first-time setup
- Step 1: 'What are you raising for?' (starts with what excites them)
- Step 2: 'Where should donors send money?' (natural follow-up)
- Step 3: 'Want auto-reminders?' (WhatsApp as bonus, skippable)
- Step 4: 'Here's your link!' (dark section with copy/WhatsApp/share)
- Auto-creates event + first pledge link during flow
- User holds a shareable link within 90 seconds of signing up

Updated: /dashboard (context-aware home)
- State 1 (no events): auto-redirects to /dashboard/welcome
- State 2 (0 pledges): shows pledge link + share buttons prominently
- State 3 (has pledges): shows stats + feed
- State 4 (has 'said paid'): amber prompt to upload bank statement
- State 5 (100% collected): celebration banner
- No more onboarding checklist — dashboard adapts instead
- Event name as page header (not generic 'Home')
- Event switcher for multi-event orgs

Updated: /signup → redirects to /dashboard/welcome (not /dashboard)

Persona spec: docs/PERSONA_JOURNEY_SPEC.md
This commit is contained in:
2026-03-04 21:01:16 +08:00
parent 170a2e7c68
commit 6fb97e1461
6 changed files with 1254 additions and 293 deletions

View File

@@ -43,7 +43,7 @@ export default function SignupPage() {
setError("Account created — please sign in")
setStep("form")
} else {
router.push("/dashboard")
router.push("/dashboard/welcome")
}
} catch {
setError("Connection error. Try again.")

View File

@@ -1,369 +1,349 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter } from "next/navigation"
import { formatPence } from "@/lib/utils"
import { ArrowRight, Loader2, MessageCircle, CheckCircle2, Circle, X } from "lucide-react"
import { ArrowRight, Loader2, MessageCircle, Copy, Check, Share2, Upload } from "lucide-react"
import Link from "next/link"
// 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.
* Context-aware dashboard.
*
* 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
*/
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" },
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" },
}
// ─── Getting Started ─────────────────────────────────────────
function GettingStarted({
ob, onSetRole, dismissed, onDismiss,
}: {
ob: OnboardingData; onSetRole: (role: string) => void; dismissed: boolean; onDismiss: () => void
}) {
if (ob.allDone || dismissed) return null
const isFirstTime = ob.completed === 0
return (
<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 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-black text-[#111827]">
{isFirstTime ? "Let's get you set up" : `Getting started — ${ob.completed} of ${ob.total} done`}
</h2>
{!isFirstTime && (
<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 && !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">
{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 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-[#16A34A] shrink-0" />
) : isNext ? (
<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 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>
)
})}
</div>
)}
</div>
)
}
// ─── Main Dashboard ─────────────────────────────────────────
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null)
const router = useRouter()
// 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 [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(null)
const [ob, setOb] = useState<OnboardingData | null>(null)
const [bannerDismissed, setBannerDismissed] = useState(false)
const [waConnected, setWaConnected] = useState<boolean | null>(null)
const [pledgeLink, setPledgeLink] = useState("")
const [copied, setCopied] = useState(false)
const fetchData = useCallback(() => {
fetch("/api/dashboard")
.then(r => r.json())
.then(d => { if (d.summary) setData(d) })
.catch(() => {})
.finally(() => setLoading(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(() => {
fetchData()
fetch("/api/whatsapp/send").then(r => r.json()).then(d => setWhatsappStatus(d.connected)).catch(() => {})
fetch("/api/onboarding").then(r => r.json()).then(d => { if (d.steps) setOb(d) }).catch(() => {})
const interval = setInterval(fetchData, 15000)
return () => clearInterval(interval)
}, [fetchData])
fetchAll()
const i = setInterval(fetchAll, 15000)
return () => clearInterval(i)
}, [fetchAll])
const handleSetRole = async (role: string) => {
await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orgType: role }),
})
const res = await fetch("/api/onboarding")
const d = await res.json()
if (d.steps) setOb(d)
// Fetch the first QR code link
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])
// ── Loading ──
if (loading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
}
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
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 topSources = data?.topSources || []
const pledges = data?.pledges || []
const topSources = data?.topSources || []
const isEmpty = s.totalPledges === 0
const hasSaidPaid = (byStatus.initiated || 0) > 0
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
),
...pledges.filter((p: { status: string }) => p.status === "initiated"),
].slice(0, 5)
const isEmpty = s.totalPledges === 0
const activeEvent = events[0]
const copyLink = async () => {
if (!pledgeLink) return
await navigator.clipboard.writeText(pledgeLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-8">
{/* Onboarding */}
{ob && !ob.allDone && (
<GettingStarted ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
)}
{/* Page header — brand typography */}
{/* ── Context header: shows event name, not generic "Home" ── */}
<div className="flex items-center justify-between">
<div>
<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-gray-400"}`}>
<MessageCircle className="h-3 w-3" />
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
</span>
)}
{isEmpty ? "Your numbers will appear here once donors start pledging" : "Updates every 15 seconds"}
</p>
{activeEvent && (
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
{events.length > 1 ? `${events.length} appeals` : "Your appeal"}
</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>
)}
</div>
{!isEmpty && (
<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" />
{events.length > 1 && (
<Link href="/dashboard/collect" className="text-xs font-semibold text-[#1E40AF] hover:underline">
Switch appeal
</Link>
)}
</div>
{/* ─── 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>
{/* ── State 2: Has event, no pledges → "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">
{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">
<Share2 className="h-3.5 w-3.5" /> Share
</button>
</div>
</div>
))}
</div>
{/* ─── 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 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="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(s.totalPledgedPence - s.totalCollectedPence)} still to come</span>
<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>
)}
{isEmpty ? (
/* 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-5 gap-6">
{/* LEFT: Needs attention + Pipeline */}
<div className="lg:col-span-2 space-y-6">
{/* Needs attention */}
{needsAttention.length > 0 && (
{/* ── State 3+: Has pledges → stats + feed ── */}
{!isEmpty && (
<>
{/* Big numbers */}
<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(s.totalPledgedPence - s.totalCollectedPence)} still to come</span>
</div>
</div>
{/* ── Contextual prompt: "said they paid" → upload bank statement ── */}
{hasSaidPaid && (
<Link href="/dashboard/reconcile">
<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-5 gap-6">
{/* LEFT column */}
<div className="lg:col-span-2 space-y-6">
{/* Needs attention */}
{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 }) => {
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>
)}
{/* How pledges are doing */}
<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 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">
{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
{Object.entries(byStatus).map(([status, count]) => {
const sl = STATUS_LABELS[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 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 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>
)}
{/* 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 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>
{/* 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>
</div>
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Top sources */}
{topSources.length > 0 && (
{/* RIGHT column: Recent pledges */}
<div className="lg:col-span-3">
<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 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">
{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>
{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" })
{/* 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" />}
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>
<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>
)
})}
{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>
)
})}
{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>
</>
)}
{/* ── 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

@@ -0,0 +1,422 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Loader2, ArrowRight, Check, MessageCircle, RefreshCw, Copy, Share2 } from "lucide-react"
/**
* /dashboard/welcome — The guided first-time setup
*
* This replaces the old "empty dashboard + checklist" pattern.
* It mirrors how Aaisha actually thinks:
* 1. "What am I raising for?" (appeal)
* 2. "Where do donors send money?" (bank)
* 3. "Can it remind them automatically?" (WhatsApp)
* 4. "Give me the link!" (share)
*
* Each step answers her CURRENT thought, not the system's requirements.
*/
type Step = "appeal" | "bank" | "whatsapp" | "ready"
export default function WelcomePage() {
const router = useRouter()
const [step, setStep] = useState<Step>("appeal")
const [loading, setLoading] = useState(false)
const [orgName, setOrgName] = useState("")
// Appeal
const [appealName, setAppealName] = useState("")
const [appealDate, setAppealDate] = useState("")
const [appealTarget, setAppealTarget] = useState("")
// Bank
const [bankName, setBankName] = useState("")
const [sortCode, setSortCode] = useState("")
const [accountNo, setAccountNo] = useState("")
const [accountName, setAccountName] = useState("")
// WhatsApp
const [waStatus, setWaStatus] = useState<string>("checking")
const [waQr, setWaQr] = useState<string | null>(null)
const [waPolling, setWaPolling] = useState(false)
// Result
const [pledgeLink, setPledgeLink] = useState("")
const [eventId, setEventId] = useState("")
const [copied, setCopied] = useState(false)
// Check if user already has an event (shouldn't see welcome again)
useEffect(() => {
fetch("/api/onboarding")
.then(r => r.json())
.then(d => {
if (d.orgName) setOrgName(d.orgName)
// If they already have events, skip to dashboard
const eventStep = d.steps?.find((s: { id: string; done: boolean }) => s.id === "event")
if (eventStep?.done) router.replace("/dashboard")
})
.catch(() => {})
}, [router])
// ── Step 1: Create the appeal ──
const createAppeal = async () => {
if (!appealName.trim()) return
setLoading(true)
try {
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: appealName.trim(),
eventDate: appealDate ? new Date(appealDate).toISOString() : undefined,
goalAmount: appealTarget ? Math.round(parseFloat(appealTarget) * 100) : undefined,
}),
})
const event = await res.json()
if (event.id) {
setEventId(event.id)
// Auto-create a pledge link
const qrRes = await fetch(`/api/events/${event.id}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: "Main link" }),
})
const qr = await qrRes.json()
if (qr.code) {
const base = typeof window !== "undefined" ? window.location.origin : ""
setPledgeLink(`${base}/p/${qr.code}`)
}
setStep("bank")
}
} catch { /* */ }
setLoading(false)
}
// ── Step 2: Save bank details ──
const saveBank = async () => {
if (!sortCode.trim() || !accountNo.trim()) return
setLoading(true)
try {
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bankName: bankName.trim(),
bankSortCode: sortCode.replace(/\s/g, "").replace(/(\d{2})(\d{2})(\d{2})/, "$1-$2-$3"),
bankAccountNo: accountNo.replace(/\s/g, ""),
bankAccountName: accountName.trim() || orgName,
}),
})
setStep("whatsapp")
} catch { /* */ }
setLoading(false)
}
// ── Step 3: WhatsApp ──
const checkWa = useCallback(async () => {
try {
const res = await fetch("/api/whatsapp/qr")
const data = await res.json()
setWaStatus(data.status)
if (data.screenshot) setWaQr(data.screenshot)
if (data.status === "CONNECTED") {
setWaPolling(false)
setStep("ready")
}
} catch { setWaStatus("ERROR") }
}, [])
const startWa = async () => {
setWaPolling(true)
try {
await fetch("/api/whatsapp/qr", { method: "POST" })
await new Promise(r => setTimeout(r, 3000))
await checkWa()
} catch { /* */ }
}
useEffect(() => {
if (!waPolling) return
const i = setInterval(checkWa, 5000)
return () => clearInterval(i)
}, [waPolling, checkWa])
const skipWa = () => setStep("ready")
// ── Step 4: Ready — share link ──
const copyLink = async () => {
await navigator.clipboard.writeText(pledgeLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const shareWa = () => {
window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge for ${appealName}:\n\n${pledgeLink}`)}`, "_blank")
}
// Step indicator
const steps: { key: Step; label: string }[] = [
{ key: "appeal", label: "Your appeal" },
{ key: "bank", label: "Bank details" },
{ key: "whatsapp", label: "WhatsApp" },
{ key: "ready", label: "Ready" },
]
const stepIdx = steps.findIndex(s => s.key === step)
return (
<div className="min-h-[calc(100vh-3.5rem)] flex items-start justify-center pt-8 md:pt-16 pb-20">
<div className="w-full max-w-lg space-y-8 px-4">
{/* ── Progress ── */}
<div className="flex items-center justify-between">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center">
<div className={`w-8 h-8 flex items-center justify-center text-xs font-black transition-all ${
i < stepIdx ? "bg-[#16A34A] text-white" :
i === stepIdx ? "bg-[#111827] text-white" :
"bg-gray-100 text-gray-400"
}`}>
{i < stepIdx ? <Check className="h-4 w-4" /> : i + 1}
</div>
{i < steps.length - 1 && (
<div className={`w-10 sm:w-16 h-0.5 mx-1 ${i < stepIdx ? "bg-[#16A34A]" : "bg-gray-200"}`} />
)}
</div>
))}
</div>
{/* ── Step 1: What are you raising for? ── */}
{step === "appeal" && (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">What are you raising for?</h1>
<p className="text-sm text-gray-500 mt-1">
This could be a gala dinner, Ramadan appeal, building fund, or any cause.
</p>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Name of your appeal</label>
<input
value={appealName}
onChange={e => setAppealName(e.target.value)}
placeholder="e.g. Ramadan Gala Dinner 2026"
autoFocus
className="w-full h-14 px-4 border-2 border-gray-200 text-base font-medium placeholder:text-gray-300 focus:border-[#1E40AF] focus:ring-4 focus:ring-[#1E40AF]/10 outline-none transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Date <span className="font-normal text-gray-400">(optional)</span></label>
<input
type="date"
value={appealDate}
onChange={e => setAppealDate(e.target.value)}
className="w-full h-11 px-3 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none transition-all"
/>
</div>
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Target £ <span className="font-normal text-gray-400">(optional)</span></label>
<input
type="number"
value={appealTarget}
onChange={e => setAppealTarget(e.target.value)}
placeholder="50000"
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all"
/>
</div>
</div>
<button
onClick={createAppeal}
disabled={!appealName.trim() || loading}
className="w-full bg-[#111827] py-3.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-2"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Next: Bank details <ArrowRight className="h-4 w-4" /></>}
</button>
</div>
<p className="text-xs text-gray-400 text-center">
You can change everything later. This just gets you started.
</p>
</div>
)}
{/* ── Step 2: Where should donors send money? ── */}
{step === "bank" && (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Where should donors send money?</h1>
<p className="text-sm text-gray-500 mt-1">
We show these bank details to donors so they can transfer directly to you. Each pledge gets a unique reference code.
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Bank name</label>
<input value={bankName} onChange={e => setBankName(e.target.value)} placeholder="e.g. Barclays, HSBC, Lloyds" className="w-full h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Sort code</label>
<input value={sortCode} onChange={e => setSortCode(e.target.value)} placeholder="20-30-80" className="w-full h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" />
</div>
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Account number</label>
<input value={accountNo} onChange={e => setAccountNo(e.target.value)} placeholder="12345678" className="w-full h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" />
</div>
</div>
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">Account name <span className="font-normal text-gray-400">(optional defaults to your charity name)</span></label>
<input value={accountName} onChange={e => setAccountName(e.target.value)} placeholder={orgName || "Account holder name"} className="w-full h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" />
</div>
</div>
<button
onClick={saveBank}
disabled={!sortCode.trim() || !accountNo.trim() || loading}
className="w-full bg-[#111827] py-3.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-2"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Next: Connect WhatsApp <ArrowRight className="h-4 w-4" /></>}
</button>
<div className="border-l-2 border-[#F59E0B] pl-3">
<p className="text-xs text-gray-500">
<strong className="text-gray-700">Why do we need this?</strong> When someone pledges, we show them your bank details
with a unique reference code (like PNPL-A8K3-50). When they transfer, you upload your bank statement and we match it automatically.
</p>
</div>
</div>
)}
{/* ── Step 3: Connect WhatsApp ── */}
{step === "whatsapp" && (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Want auto-reminders?</h1>
<p className="text-sm text-gray-500 mt-1">
Connect WhatsApp and we&apos;ll automatically remind donors to pay. You don&apos;t have to chase anyone.
</p>
</div>
<div className="border-l-2 border-[#25D366] pl-4 space-y-1.5 text-xs text-gray-600">
<p className="font-bold text-[#111827]">When you connect, donors automatically get:</p>
<p> A receipt with your bank details after they pledge</p>
<p> A gentle reminder if they haven&apos;t paid after 2 days</p>
<p> A follow-up with an &quot;I&apos;ve paid&quot; button after 7 days</p>
<p> A final reminder after 14 days</p>
<p> They can reply PAID, HELP, or CANCEL anytime</p>
</div>
{waPolling && waStatus === "SCAN_QR_CODE" && waQr ? (
<div className="flex flex-col items-center gap-4">
<div className="w-56 h-56 border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={waQr} alt="WhatsApp QR" className="w-[200%] h-auto max-w-none" style={{ marginLeft: "-30%", marginTop: "-35%" }} />
</div>
<div className="text-center">
<p className="text-sm font-bold text-[#111827]">Scan this with your phone</p>
<p className="text-xs text-gray-500">WhatsApp Settings Linked Devices Link a Device</p>
</div>
<button onClick={checkWa} className="text-xs text-gray-500 hover:text-[#111827] flex items-center gap-1">
<RefreshCw className="h-3 w-3" /> Refresh
</button>
</div>
) : waStatus === "CONNECTED" ? (
<div className="bg-[#25D366]/5 border border-[#25D366]/20 p-4 text-center">
<Check className="h-6 w-6 text-[#25D366] mx-auto mb-2" />
<p className="text-sm font-bold text-[#111827]">WhatsApp connected!</p>
<p className="text-xs text-gray-500">Donors will now get automatic reminders.</p>
</div>
) : (
<button
onClick={startWa}
disabled={waPolling}
className="w-full bg-[#25D366] py-3.5 text-sm font-bold text-white hover:bg-[#25D366]/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
{waPolling ? <Loader2 className="h-4 w-4 animate-spin" /> : <><MessageCircle className="h-4 w-4" /> Connect WhatsApp</>}
</button>
)}
<button
onClick={skipWa}
className="w-full py-2.5 text-xs font-semibold text-gray-400 hover:text-gray-600 transition-colors"
>
Skip for now I&apos;ll do this later in Settings
</button>
</div>
)}
{/* ── Step 4: Ready — Here's your link! ── */}
{step === "ready" && (
<div className="space-y-6">
<div className="text-center">
<div className="w-14 h-14 bg-[#16A34A]/10 flex items-center justify-center mx-auto mb-4">
<Check className="h-7 w-7 text-[#16A34A]" />
</div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">You&apos;re ready to collect pledges</h1>
<p className="text-sm text-gray-500 mt-1">
Share this link with donors they can pledge in 60 seconds.
</p>
</div>
{/* The main event — the pledge link */}
<div className="bg-[#111827] p-6 text-center space-y-4">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">{appealName}</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">
{copied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Copy className="h-3.5 w-3.5" /> Copy link</>}
</button>
<button onClick={shareWa} 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: appealName })} 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">
<Share2 className="h-3.5 w-3.5" /> Share
</button>
</div>
</div>
{/* What happens next */}
<div className="space-y-2">
<p className="text-xs font-bold text-[#111827]">What happens next</p>
<div className="grid gap-px bg-gray-200">
{[
{ num: "01", text: "Donors open the link and pledge an amount" },
{ num: "02", text: "They see your bank details with a unique reference" },
{ num: "03", text: "They transfer the money using that reference" },
{ num: "04", text: "We remind them on WhatsApp until they pay" },
{ num: "05", text: "Upload your bank statement — we match payments automatically" },
].map(s => (
<div key={s.num} className="bg-white px-4 py-3 flex items-center gap-3">
<span className="text-lg font-black text-gray-200">{s.num}</span>
<span className="text-xs text-gray-600">{s.text}</span>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => router.push(`/dashboard/events/${eventId}`)}
className="border-2 border-gray-200 py-2.5 text-xs font-bold text-[#111827] hover:bg-gray-50 transition-colors"
>
Add more links
</button>
<button
onClick={() => router.push("/dashboard")}
className="bg-[#111827] py-2.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors"
>
Go to dashboard
</button>
</div>
</div>
)}
</div>
</div>
)
}