From 6fb97e1461b36e78384ceed74c84cacffc50e4e7 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Wed, 4 Mar 2026 21:01:16 +0800 Subject: [PATCH] Telepathic onboarding: welcome flow + context-aware dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../docs/PERSONA_JOURNEY_SPEC.md | 220 +++++++ .../src/app/(auth)/signup/page.tsx | 2 +- .../src/app/dashboard/page.tsx | 562 +++++++++--------- .../src/app/dashboard/welcome/page.tsx | 422 +++++++++++++ temp_files/AIAppealReviewService.php | 2 +- temp_files/EditApprovalQueue.php | 339 +++++++++++ 6 files changed, 1254 insertions(+), 293 deletions(-) create mode 100644 pledge-now-pay-later/docs/PERSONA_JOURNEY_SPEC.md create mode 100644 pledge-now-pay-later/src/app/dashboard/welcome/page.tsx create mode 100644 temp_files/EditApprovalQueue.php diff --git a/pledge-now-pay-later/docs/PERSONA_JOURNEY_SPEC.md b/pledge-now-pay-later/docs/PERSONA_JOURNEY_SPEC.md new file mode 100644 index 0000000..4332d1a --- /dev/null +++ b/pledge-now-pay-later/docs/PERSONA_JOURNEY_SPEC.md @@ -0,0 +1,220 @@ +# Deep Persona Analysis + Journey Redesign + +## The Core Insight + +The current product asks users to learn its structure. +The redesigned product should **mirror how users already think**. + +Users don't arrive thinking "I need to create a campaign then generate QR sources." +They arrive thinking **"I have an event on Saturday and I need people to pledge."** + +The gap between those two thoughts is where every user drops off. + +--- + +## Persona Deep Dive + +### Persona A: Aaisha — Charity Fundraising Manager + +**Who she is:** +- 32, works at a medium Islamic charity (£500k–£2M annual income) +- Not technical. Uses Canva, WhatsApp, basic Excel +- Her boss says "we need to collect pledges at the gala" and she's the one who makes it happen +- She's done this before with paper forms and a shared Google Sheet. It was a nightmare. +- She found PNPL from a WhatsApp forward or saw it on the landing page + +**Her mental model (what she thinks is happening):** +1. "I sign up" → She expects to land somewhere that asks "what's your event?" +2. "I tell it about our dinner" → She expects it to set everything up for her +3. "I get links to share" → She expects to be holding something shareable within 2 minutes +4. "People pledge" → She expects to see names and amounts appear +5. "The system chases them" → She expects this to happen automatically +6. "Money arrives" → She expects to see green ticks +7. "I download a report" → She expects a spreadsheet for her treasurer + +**Her assumptions (things she takes for granted):** +- "It'll ask me for my bank details so people know where to pay" +- "There'll be a link I can send on WhatsApp" +- "It'll remind people automatically — that's the whole point" +- "I can see who's paid and who hasn't" +- "Gift Aid should be handled" + +**Her fears:** +- "Is this going to be complicated to set up?" +- "Will donors find this confusing?" +- "What if someone pledges and we lose track of it?" +- "Am I going to have to learn another system?" + +**What she does NOT know she needs (until she's in the middle of it):** +- She doesn't know she needs to connect WhatsApp first +- She doesn't know each volunteer needs their own link +- She doesn't know bank reconciliation is a feature +- She doesn't know about the chatbot + +**The golden path for Aaisha:** +``` +Signup → "What's your event?" → Bank details → WhatsApp QR scan → +→ Auto-generated pledge link → Copy to clipboard → DONE +→ She's on the dashboard watching pledges come in +``` + +Time to value: under 3 minutes. She should be holding a shareable link +before she has time to wonder if this was worth signing up for. + +--- + +### Persona B: Yusuf — Volunteer / Table Captain + +**Who he is:** +- 45, mosque committee member, not technical at all +- Aaisha sent him a link on WhatsApp saying "use this at your table tonight" +- He opens `/v/[code]` on his phone +- He's standing at a banquet table with a phone and maybe a printed QR + +**He never sees the dashboard.** +His journey is: open link → show QR → watch numbers go up → feel proud. + +**We don't need to redesign anything for Yusuf** — his view is separate. +But we do need to make sure **Aaisha can get him his link easily**. + +--- + +### Persona C: Fatima — Treasurer / Trustee + +**Who she is:** +- 55, retired accountant, meticulous +- Logs in once a month, maybe twice +- Wants: Gift Aid CSV, total collected vs outstanding, clean data + +**Her mental model:** +1. "Show me the numbers" → She wants a summary, not a feed +2. "Can I download this?" → She needs CSVs for her spreadsheet +3. "Which bank payments match which pledges?" → She'll upload a bank statement +4. "Where's the Gift Aid report?" → She needs it for HMRC + +**The key insight about Fatima:** +She doesn't need onboarding. She needs **Reports** to be front and center +when she arrives. The dashboard should detect that she's a returning user +with data and show her the summary, not a getting-started wizard. + +--- + +## The Journey Redesign + +### Problem 1: Signup → Dashboard is a dead end + +**Current flow:** +``` +Signup (charity name, email, password) +→ Redirect to /dashboard +→ Empty dashboard with "Getting Started" checklist +→ User has to figure out what to do next +``` + +**Why this fails:** +- The dashboard is empty. There's nothing to see. +- The checklist says "Add bank details" — but she was thinking about her EVENT +- She's now in "Settings" adding bank details when she wanted to be sharing links +- By the time she gets back to the dashboard, she's forgotten what she came for + +**Redesigned flow:** +``` +Signup (charity name, email, password) +→ Redirect to /dashboard/welcome (NEW — a single-page guided setup) +→ Step 1: "What are you raising for?" (event name + optional date/target) +→ Step 2: "Where should donors send money?" (bank details) +→ Step 3: "Connect WhatsApp" (QR scan — or skip for now) +→ Done: "Here's your pledge link" (big, copyable, with share buttons) +→ Auto-redirect to dashboard (which now has an event and a link) +``` + +**Why this works:** +- Starts with the thing she's excited about (her event) +- Bank details feel like a natural follow-up ("okay, where do people pay?") +- WhatsApp is presented as a bonus ("want auto-reminders? connect here") +- She ends up holding a shareable link — the actual thing she came for +- The dashboard is no longer empty when she arrives + +### Problem 2: The dashboard doesn't match her mental state + +**Current dashboard for a new user:** Empty stat cards, a checklist, generic copy. + +**What she's actually thinking at this point:** +- "Did it work? Is my link live?" +- "How do I share this with my volunteers?" +- "When people pledge, will I see it here?" + +**Redesigned dashboard for a new user (has 1 event, 0 pledges):** +``` +┌────────────────────────────────────────────────┐ +│ Your appeal is live [Appeal] │ +│ Ramadan Gala 2026 │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 📋 Your pledge link │ │ +│ │ pledge.quikcue.com/p/A8K3Y2 │ │ +│ │ [Copy] [WhatsApp] [Email] [Print QR] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ 0 pledges so far — share your link to start │ +│ │ +│ ┌──────────┐ ┌────────────────────────────┐ │ +│ │ Add more │ │ Give each volunteer their │ │ +│ │ links │ │ own link to track who │ │ +│ │ │ │ brings in the most pledges │ │ +│ └──────────┘ └────────────────────────────┘ │ +│ │ +│ What happens next: │ +│ 1. Donors scan or click your link │ +│ 2. They pledge in 60 seconds │ +│ 3. They get a WhatsApp receipt │ +│ 4. We remind them until they pay │ +│ 5. You see it all here │ +└────────────────────────────────────────────────┘ +``` + +**Redesigned dashboard for a returning user (has pledges):** +``` +┌────────────────────────────────────────────────┐ +│ Ramadan Gala 2026 [Switch appeal] │ +│ │ +│ ┌──────┬──────┬──────┬──────┐ ← gap-px grid │ +│ │ 47 │£12.4k│£8.2k │ 66% │ │ +│ │pledg │promi │recvd │colle │ │ +│ └──────┴──────┴──────┴──────┘ │ +│ │ +│ ██████████████░░░░░░ 66% received │ +│ │ +│ ┌── Needs attention (3) ──────────────────────┐ │ +│ │ Ahmed K — £50 — said he paid 3 days ago │ │ +│ │ Sarah M — £100 — needs a nudge (10d) │ │ +│ │ Omar R — £200 — waiting since 5 Mar │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌── Recent ────────────────── [View all] ─────┐ │ +│ │ Fatima A £50 Received ✓ Today │ │ +│ │ Bilal H £100 Waiting Yesterday │ │ +│ │ Mariam K £75 Said paid 2d ago │ │ +│ └─────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────┘ +``` + +### Problem 3: The onboarding checklist is a list of chores + +**Current:** A dismissible checklist that feels like homework. + +**Redesigned:** No checklist. The system **just does the next thing**. + +After signup → welcome flow, there is NO checklist on the dashboard. +Instead, the dashboard **adapts its content** based on what's missing: + +| State | Dashboard shows | +|-------|----------------| +| No WhatsApp | Amber bar: "Connect WhatsApp for auto-reminders" (already exists) | +| No pledges | "Share your link to start collecting" with share buttons | +| Has pledges, no bank imports | "3 people said they paid. Upload your bank statement to confirm." | +| Has overdue | "Needs attention" section is promoted to top | +| All paid | Celebration: "All pledges collected! 🎉" | +| Returning user, 2+ events | Event switcher at top | + +The system anticipates her next thought at every stage. diff --git a/pledge-now-pay-later/src/app/(auth)/signup/page.tsx b/pledge-now-pay-later/src/app/(auth)/signup/page.tsx index 00b0f73..a019600 100644 --- a/pledge-now-pay-later/src/app/(auth)/signup/page.tsx +++ b/pledge-now-pay-later/src/app/(auth)/signup/page.tsx @@ -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.") diff --git a/pledge-now-pay-later/src/app/dashboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/page.tsx index e152e88..4bc311d 100644 --- a/pledge-now-pay-later/src/app/dashboard/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/page.tsx @@ -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 = { 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 ( -
- - -
-
- P -
-
-

- {isFirstTime ? "Let's get you set up" : `Getting started — ${ob.completed} of ${ob.total} done`} -

- {!isFirstTime && ( -
- {ob.steps.map((step: { id: string; done: boolean }) => ( -
- ))} -
- )} -
-
- - {isFirstTime && !ob.orgType ? ( -
- - -
- ) : ( -
- {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 ( - -
- {step.done ? ( - - ) : isNext ? ( -
{i + 1}
- ) : ( - - )} - - {step.label} - - {isNext && } -
- - ) - })} -
- )} -
- ) -} - -// ─── Main Dashboard ───────────────────────────────────────── - export default function DashboardPage() { - const [data, setData] = useState(null) + const router = useRouter() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [data, setData] = useState(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [ob, setOb] = useState(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) - const [whatsappStatus, setWhatsappStatus] = useState(null) - const [ob, setOb] = useState(null) - const [bannerDismissed, setBannerDismissed] = useState(false) + const [waConnected, setWaConnected] = useState(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
} - if (loading) { - return ( -
- -
- ) + // ── 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
+ } } 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 (
- {/* Onboarding */} - {ob && !ob.allDone && ( - setBannerDismissed(true)} /> - )} - {/* Page header — brand typography */} + {/* ── Context header: shows event name, not generic "Home" ── */}
-

Home

-

- {whatsappStatus !== null && ( - - - {whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"} - - )} - {isEmpty ? "Your numbers will appear here once donors start pledging" : "Updates every 15 seconds"} -

+ {activeEvent && ( +

+ {events.length > 1 ? `${events.length} appeals` : "Your appeal"} +

+ )} +

+ {activeEvent?.name || "Home"} +

+ {waConnected !== null && ( +

+ + {waConnected ? "WhatsApp connected" : "WhatsApp not connected"} +

+ )}
- {!isEmpty && ( - - View all + {events.length > 1 && ( + + Switch appeal → )}
- {/* ─── Big Numbers — gap-px grid (brand pattern) ─── */} -
- {[ - { 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) => ( -
-

- {stat.value} -

-

{stat.label}

+ {/* ── State 2: Has event, no pledges → "Share your link" ── */} + {isEmpty && pledgeLink && ( +
+
+

Your pledge link

+

{pledgeLink}

+
+ + + +
- ))} -
- {/* ─── Collection Progress — brand bar ─── */} - {!isEmpty && ( -
-
- Promised → Received - {s.collectionRate}% +
+

No pledges yet — share your link and they'll appear here

-
-
-
-
- {formatPence(s.totalCollectedPence)} received - {formatPence(s.totalPledgedPence - s.totalCollectedPence)} still to come + +
+ + Create more pledge links → + +

Give each volunteer or table their own link to see who brings in the most pledges

)} - {isEmpty ? ( - /* Empty state — clean, directive */ -
-

01

-

Share your first pledge link

-

- Create an appeal, share the link with donors, and watch pledges come in here. -

- - - -
- ) : ( -
- {/* LEFT: Needs attention + Pipeline */} -
- {/* Needs attention */} - {needsAttention.length > 0 && ( + {/* ── State 3+: Has pledges → stats + feed ── */} + {!isEmpty && ( + <> + {/* Big numbers */} +
+ {[ + { 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 => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Progress bar */} +
+
+ Promised → Received + {s.collectionRate}% +
+
+
+
+
+ {formatPence(s.totalCollectedPence)} received + {formatPence(s.totalPledgedPence - s.totalCollectedPence)} still to come +
+
+ + {/* ── Contextual prompt: "said they paid" → upload bank statement ── */} + {hasSaidPaid && ( + +
+ +
+

{byStatus.initiated} {byStatus.initiated === 1 ? "person says" : "people say"} they've paid

+

Upload your bank statement to confirm their payments automatically

+
+ +
+ + )} + +
+ {/* LEFT column */} +
+ {/* Needs attention */} + {needsAttention.length > 0 && ( +
+
+

Needs attention

+ {needsAttention.length} +
+
+ {needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string }) => { + const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new + return ( +
+
+

{p.donorName || "Anonymous"}

+

{formatPence(p.amountPence)}

+
+ {sl.label} +
+ ) + })} +
+
+ View all → +
+
+ )} + + {/* How pledges are doing */}
-
-

Needs attention

- {needsAttention.length} +
+

How pledges are doing

- {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 ( -
-
-

{p.donorName || "Anonymous"}

-

{formatPence(p.amountPence)} · {p.eventName}

-
- {sl.label} +
+ {sl.label} + {count as number}
) })}
-
- - View all → - -
- )} - {/* How pledges are doing — gap-px grid */} -
-
-

How pledges are doing

-
-
- {Object.entries(byStatus).map(([status, count]) => { - const sl = STATUS_LABELS[status] || STATUS_LABELS.new - return ( -
- {sl.label} - {count as number} -
- ) - })} -
+ {/* Top sources */} + {topSources.length > 0 && ( +
+
+

Where pledges come from

+
+
+ {topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => ( +
+
+ {i + 1} + {src.label} +
+ {formatPence(src.amount)} +
+ ))} +
+
+ )}
- {/* Top sources */} - {topSources.length > 0 && ( + {/* RIGHT column: Recent pledges */} +
-
-

Where pledges come from

+
+

Recent pledges

+ View all
- {topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => ( -
-
- {i + 1} - {src.label} - {src.count} pledges -
- {formatPence(src.amount)} -
- ))} -
-
- )} -
+ {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 */} -
-
-
-

Recent pledges

- - View all - -
-
- {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 ( -
-
- {initial} -
-
-
-

{p.donorName || "Anonymous"}

- {p.donorPhone && } + return ( +
+
+ {initial} +
+
+
+

{p.donorName || "Anonymous"}

+ {p.donorPhone && } +
+

{when}

+
+
+

{formatPence(p.amountPence)}

+ {sl.label}
-

- {p.eventName} · {timeLabel} - {p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`} -

-
-
-

{formatPence(p.amountPence)}

- {sl.label}
+ ) + })} + {recentPledges.length === 0 && ( +
+ Pledges will appear here as they come in
- ) - })} - {recentPledges.length === 0 && ( -
- Pledges will appear here as they come in -
- )} + )} +
+ + )} + + {/* ── State: all paid → celebration ── */} + {!isEmpty && s.collectionRate === 100 && ( +
+

Every pledge collected

+

{formatPence(s.totalCollectedPence)} received from {s.totalPledges} donors

)}
diff --git a/pledge-now-pay-later/src/app/dashboard/welcome/page.tsx b/pledge-now-pay-later/src/app/dashboard/welcome/page.tsx new file mode 100644 index 0000000..2347427 --- /dev/null +++ b/pledge-now-pay-later/src/app/dashboard/welcome/page.tsx @@ -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("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("checking") + const [waQr, setWaQr] = useState(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 ( +
+
+ + {/* ── Progress ── */} +
+ {steps.map((s, i) => ( +
+
+ {i < stepIdx ? : i + 1} +
+ {i < steps.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* ── Step 1: What are you raising for? ── */} + {step === "appeal" && ( +
+
+

What are you raising for?

+

+ This could be a gala dinner, Ramadan appeal, building fund, or any cause. +

+
+ +
+
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+ +

+ You can change everything later. This just gets you started. +

+
+ )} + + {/* ── Step 2: Where should donors send money? ── */} + {step === "bank" && ( +
+
+

Where should donors send money?

+

+ We show these bank details to donors so they can transfer directly to you. Each pledge gets a unique reference code. +

+
+ +
+
+ + 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" /> +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + 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" /> +
+
+ + + +
+

+ Why do we need this? 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. +

+
+
+ )} + + {/* ── Step 3: Connect WhatsApp ── */} + {step === "whatsapp" && ( +
+
+

Want auto-reminders?

+

+ Connect WhatsApp and we'll automatically remind donors to pay. You don't have to chase anyone. +

+
+ +
+

When you connect, donors automatically get:

+

• A receipt with your bank details after they pledge

+

• A gentle reminder if they haven't paid after 2 days

+

• A follow-up with an "I've paid" button after 7 days

+

• A final reminder after 14 days

+

• They can reply PAID, HELP, or CANCEL anytime

+
+ + {waPolling && waStatus === "SCAN_QR_CODE" && waQr ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + WhatsApp QR +
+
+

Scan this with your phone

+

WhatsApp → Settings → Linked Devices → Link a Device

+
+ +
+ ) : waStatus === "CONNECTED" ? ( +
+ +

WhatsApp connected!

+

Donors will now get automatic reminders.

+
+ ) : ( + + )} + + +
+ )} + + {/* ── Step 4: Ready — Here's your link! ── */} + {step === "ready" && ( +
+
+
+ +
+

You're ready to collect pledges

+

+ Share this link with donors — they can pledge in 60 seconds. +

+
+ + {/* The main event — the pledge link */} +
+

{appealName}

+

{pledgeLink}

+
+ + + +
+
+ + {/* What happens next */} +
+

What happens next

+
+ {[ + { 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 => ( +
+ {s.num} + {s.text} +
+ ))} +
+
+ +
+ + +
+
+ )} +
+
+ ) +} diff --git a/temp_files/AIAppealReviewService.php b/temp_files/AIAppealReviewService.php index dab7a28..1c6615d 100644 --- a/temp_files/AIAppealReviewService.php +++ b/temp_files/AIAppealReviewService.php @@ -61,7 +61,7 @@ class AIAppealReviewService } elseif ($result['decision'] === 'reject') { $item->update([ 'status' => 'change_requested', - 'message' => 'Auto-flagged: ' . implode('; ', $result['reasons']), + 'message' => substr('Auto-flagged: ' . implode('; ', $result['reasons']), 0, 250), ]); $appeal = $item->appeal; diff --git a/temp_files/EditApprovalQueue.php b/temp_files/EditApprovalQueue.php new file mode 100644 index 0000000..3f3dab7 --- /dev/null +++ b/temp_files/EditApprovalQueue.php @@ -0,0 +1,339 @@ +schema([ + // AI Review Summary (if available) + Section::make('AI Review') + ->icon('heroicon-o-sparkles') + ->description('Automated compliance check results') + ->schema([ + Placeholder::make('ai_summary') + ->label('') + ->content(function () { + $extra = json_decode($this->record->extra_data ?? '{}', true); + $ai = $extra['ai_review'] ?? null; + + if (!$ai) { + return new HtmlString( + '
' . + '

No AI review has been run yet. Click "Run AI Review" above to check this fundraiser.

' . + '
' + ); + } + + $decision = $ai['decision'] ?? 'unknown'; + $confidence = round(($ai['confidence'] ?? 0) * 100); + $summary = $ai['summary'] ?? ''; + $reasons = $ai['reasons'] ?? []; + $flags = $ai['flags'] ?? []; + + $colorMap = ['approve' => 'green', 'reject' => 'red', 'review' => 'amber']; + $iconMap = ['approve' => '✓', 'reject' => '✗', 'review' => '?']; + $labelMap = ['approve' => 'Safe to Approve', 'reject' => 'Should Be Rejected', 'review' => 'Needs Your Judgment']; + + $color = $colorMap[$decision] ?? 'gray'; + $icon = $iconMap[$decision] ?? '?'; + $label = $labelMap[$decision] ?? 'Unknown'; + + $html = "
"; + $html .= "
"; + $html .= "{$icon}"; + $html .= "
"; + $html .= "

{$label}

"; + $html .= "

Confidence: {$confidence}%

"; + $html .= "
"; + $html .= "

{$summary}

"; + + if (!empty($reasons)) { + $html .= "
    "; + foreach ($reasons as $reason) { + $html .= "
  • • {$reason}
  • "; + } + $html .= "
"; + } + + if (!empty($flags)) { + $html .= "
"; + foreach ($flags as $flag) { + $html .= "{$flag}"; + } + $html .= "
"; + } + + $html .= "
"; + + return new HtmlString($html); + }), + ]) + ->collapsible(), + + // Fundraiser Details + Section::make('Fundraiser Details') + ->icon('heroicon-o-document-text') + ->description('What the supporter submitted') + ->schema([ + Fieldset::make('Basic Info')->schema([ + Placeholder::make('appeal_name') + ->label('Fundraiser Name') + ->content(fn () => $this->record->appeal?->name ?? '—'), + + Placeholder::make('appeal_owner') + ->label('Created By') + ->content(fn () => ($this->record->appeal?->user?->name ?? '—') . ' (' . ($this->record->appeal?->user?->email ?? '') . ')'), + + Placeholder::make('appeal_type') + ->label('Cause') + ->content(fn () => $this->record->appeal?->donationType?->display_name ?? '—'), + + Placeholder::make('appeal_target') + ->label('Fundraising Goal') + ->content(fn () => '£' . number_format($this->record->appeal?->amount_to_raise ?? 0, 0)), + + Placeholder::make('appeal_status') + ->label('Current Status') + ->content(function () { + $status = $this->record->status; + return match ($status) { + 'pending' => new HtmlString('⏳ Waiting for Review'), + 'confirmed' => new HtmlString('✓ Approved'), + 'change_requested' => new HtmlString('✗ Changes Requested'), + default => ucfirst($status), + }; + }), + + Placeholder::make('submission_type') + ->label('Submission Type') + ->content(function () { + return match ($this->record->action) { + 'Create' => 'Brand new fundraiser', + 'Update' => 'Editing an existing fundraiser', + default => $this->record->action, + }; + }), + ])->columns(3), + + Placeholder::make('appeal_description') + ->label('Description') + ->content(fn () => $this->record->appeal?->description ?? '—') + ->columnSpanFull(), + + Placeholder::make('appeal_story') + ->label('Story') + ->content(fn () => new HtmlString( + '
' . ($this->record->appeal?->story ?? 'No story provided') . '
' + )) + ->columnSpanFull(), + + Placeholder::make('appeal_image') + ->label('Cover Image') + ->content(function () { + $url = $this->record->appeal?->getPictureUrl(); + if (!$url) return new HtmlString('No image'); + return new HtmlString( + "" + ); + }) + ->columnSpanFull(), + ]) + ->collapsible(), + + // What Changed (for updates only) + Section::make('What Changed') + ->icon('heroicon-o-pencil-square') + ->description('Fields modified since the last approved version') + ->visible(fn () => $this->record->action === 'Update') + ->schema([ + Placeholder::make('changes_list') + ->label('') + ->content(function () { + $changes = json_decode($this->record->extra_data ?? '[]', true); + // Handle nested ai_review structure + if (isset($changes['previous_extra_data'])) { + $changes = json_decode($changes['previous_extra_data'] ?? '[]', true); + } + if (!is_array($changes) || empty($changes)) { + return 'No specific changes recorded.'; + } + + $friendlyNames = [ + 'name' => 'Fundraiser name', + 'description' => 'Description', + 'story' => 'Story content', + 'picture' => 'Cover image', + 'amount_to_raise' => 'Fundraising goal', + 'donation_type_id' => 'Cause', + 'donation_country_id' => 'Country', + 'is_in_memory' => 'In memory setting', + 'in_memory_name' => 'Memorial name', + 'is_visible' => 'Visibility', + 'is_team_campaign' => 'Team campaign setting', + 'is_accepting_members' => 'Member acceptance', + 'is_custom_story' => 'Custom story setting', + 'expires_at' => 'End date', + 'parent_appeal_id' => 'Parent fundraiser', + ]; + + $html = '
    '; + foreach ($changes as $field) { + if (is_string($field)) { + $label = $friendlyNames[$field] ?? ucfirst(str_replace('_', ' ', $field)); + $html .= "
  • {$label} was modified
  • "; + } + } + $html .= '
'; + + return new HtmlString($html); + }), + ]) + ->collapsible() + ->collapsed(), + ]); + } + + public function getHeading(): string + { + return 'Review: ' . ($this->record->appeal?->name ?? 'Unknown Fundraiser'); + } + + public function getSubheading(): string + { + return match ($this->record->status) { + 'pending' => 'This fundraiser is waiting for your review. Check the details below and approve or request changes.', + 'confirmed' => 'This fundraiser has been approved and is live on the website.', + 'change_requested' => 'Changes have been requested. The fundraiser creator has been notified.', + default => '', + }; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + return $data; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('ai_review') + ->label('Run AI Review') + ->icon('heroicon-o-sparkles') + ->color('info') + ->visible(fn () => $this->record->status === 'pending') + ->action(function () { + $service = app(AIAppealReviewService::class); + $result = $service->review($this->record->appeal); + + $this->record->update([ + 'extra_data' => json_encode([ + 'ai_review' => $result, + 'previous_extra_data' => $this->record->extra_data, + ]), + ]); + + $decisionLabels = [ + 'approve' => 'AI recommends APPROVING', + 'reject' => 'AI recommends REJECTING', + 'review' => 'AI is UNCERTAIN — needs your judgment', + ]; + + Notification::make() + ->title($decisionLabels[$result['decision']] ?? 'Review complete') + ->body($result['summary']) + ->color(match ($result['decision']) { + 'approve' => 'success', + 'reject' => 'danger', + default => 'warning', + }) + ->persistent() + ->send(); + + $this->fillForm(); + }), + + Action::make('approve') + ->label('Approve Fundraiser') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->modalHeading('Approve this fundraiser?') + ->modalDescription(fn () => "This will make \"{$this->record->appeal?->name}\" visible on the CharityRight website. The fundraiser creator will be notified by email.") + ->modalSubmitActionLabel('Yes, Approve') + ->visible(fn () => $this->record->status === 'pending') + ->action(function () { + app(ApprovalQueueService::class)->approveAppeal($this->record); + + Notification::make() + ->title('Fundraiser approved! 🎉') + ->body('The fundraiser is now live and the creator has been notified.') + ->success() + ->send(); + + return redirect()->route('filament.admin.resources.approval-queues.index'); + }), + + Action::make('request_changes') + ->label('Request Changes') + ->icon('heroicon-o-pencil-square') + ->color('warning') + ->visible(fn () => $this->record->status === 'pending') + ->form([ + Textarea::make('message') + ->label('What needs to change?') + ->placeholder("Tell the fundraiser creator what to fix. Be specific and friendly.\n\nExample: \"Your story mentions a travel company — fundraisers must be about charitable causes only. Please rewrite your story to focus on the people you're helping.\"") + ->required() + ->rows(4), + ]) + ->action(function (array $data) { + $this->record->update(['message' => $data['message']]); + app(ApprovalQueueService::class)->requestChange($this->record); + + Notification::make() + ->title('Change request sent') + ->body('The fundraiser creator has been notified and asked to make changes.') + ->warning() + ->send(); + + return redirect()->route('filament.admin.resources.approval-queues.index'); + }), + + Action::make('view_appeal') + ->label('Open Fundraiser') + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(fn () => $this->record->appeal_id + ? AppealResource::getUrl('edit', ['record' => $this->record->appeal_id]) + : null) + ->openUrlInNewTab(), + ]; + } + + protected function getSavedNotification(): ?Notification + { + return null; // We handle notifications in our custom actions + } +}