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:
220
pledge-now-pay-later/docs/PERSONA_JOURNEY_SPEC.md
Normal file
220
pledge-now-pay-later/docs/PERSONA_JOURNEY_SPEC.md
Normal file
@@ -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.
|
||||
@@ -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.")
|
||||
|
||||
@@ -1,217 +1,198 @@
|
||||
"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 }),
|
||||
// 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}`)
|
||||
}
|
||||
})
|
||||
const res = await fetch("/api/onboarding")
|
||||
const d = await res.json()
|
||||
if (d.steps) setOb(d)
|
||||
.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"}
|
||||
{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) ─── */}
|
||||
{/* ── 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 className="text-center py-6">
|
||||
<p className="text-sm text-gray-500">No pledges yet — share your link and they'll appear here</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4">
|
||||
<Link href={`/dashboard/events/${activeEvent?.id}`} className="text-sm font-bold text-[#111827] hover:text-[#1E40AF] transition-colors">
|
||||
Create more pledge links →
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ── 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", sub: isEmpty ? "—" : undefined },
|
||||
{ value: String(s.totalPledges), label: "Pledges" },
|
||||
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
|
||||
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: true },
|
||||
{ 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 ${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>
|
||||
].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>
|
||||
|
||||
{/* ─── Collection Progress — brand bar ─── */}
|
||||
{!isEmpty && (
|
||||
{/* 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>
|
||||
@@ -225,25 +206,23 @@ export default function DashboardPage() {
|
||||
<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'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>
|
||||
)}
|
||||
|
||||
{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 */}
|
||||
{/* LEFT column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Needs attention */}
|
||||
{needsAttention.length > 0 && (
|
||||
@@ -253,13 +232,13 @@ export default function DashboardPage() {
|
||||
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; dueDate: string | null }) => {
|
||||
{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.eventName}</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>
|
||||
@@ -267,14 +246,12 @@ export default function DashboardPage() {
|
||||
})}
|
||||
</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>
|
||||
<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 */}
|
||||
{/* How pledges are doing */}
|
||||
<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>
|
||||
@@ -304,7 +281,6 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
@@ -314,14 +290,12 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Recent pledges */}
|
||||
{/* 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 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>
|
||||
<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: {
|
||||
@@ -331,8 +305,8 @@ export default function DashboardPage() {
|
||||
}) => {
|
||||
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" })
|
||||
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">
|
||||
@@ -344,10 +318,7 @@ export default function DashboardPage() {
|
||||
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||
{p.donorPhone && <MessageCircle className="h-3 w-3 text-[#25D366] shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{p.eventName} · {timeLabel}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
<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>
|
||||
@@ -365,6 +336,15 @@ export default function DashboardPage() {
|
||||
</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>
|
||||
)
|
||||
|
||||
422
pledge-now-pay-later/src/app/dashboard/welcome/page.tsx
Normal file
422
pledge-now-pay-later/src/app/dashboard/welcome/page.tsx
Normal 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'll automatically remind donors to pay. You don'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't paid after 2 days</p>
|
||||
<p>• A follow-up with an "I've paid" 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'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'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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
339
temp_files/EditApprovalQueue.php
Normal file
339
temp_files/EditApprovalQueue.php
Normal file
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ApprovalQueueResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AppealResource;
|
||||
use App\Filament\Resources\ApprovalQueueResource;
|
||||
use App\Services\AIAppealReviewService;
|
||||
use App\Services\ApprovalQueueService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Fieldset;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EditApprovalQueue extends EditRecord
|
||||
{
|
||||
protected static string $resource = ApprovalQueueResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->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(
|
||||
'<div class="rounded-lg bg-gray-50 p-4 text-gray-500 text-sm">' .
|
||||
'<p>No AI review has been run yet. Click "Run AI Review" above to check this fundraiser.</p>' .
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
$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 = "<div class='rounded-lg bg-{$color}-50 border border-{$color}-200 p-4'>";
|
||||
$html .= "<div class='flex items-center gap-3 mb-2'>";
|
||||
$html .= "<span class='text-2xl'>{$icon}</span>";
|
||||
$html .= "<div>";
|
||||
$html .= "<p class='font-semibold text-{$color}-800 text-lg'>{$label}</p>";
|
||||
$html .= "<p class='text-{$color}-600 text-sm'>Confidence: {$confidence}%</p>";
|
||||
$html .= "</div></div>";
|
||||
$html .= "<p class='text-gray-700 mt-2'>{$summary}</p>";
|
||||
|
||||
if (!empty($reasons)) {
|
||||
$html .= "<ul class='mt-2 space-y-1'>";
|
||||
foreach ($reasons as $reason) {
|
||||
$html .= "<li class='text-sm text-gray-600'>• {$reason}</li>";
|
||||
}
|
||||
$html .= "</ul>";
|
||||
}
|
||||
|
||||
if (!empty($flags)) {
|
||||
$html .= "<div class='mt-3 flex gap-2 flex-wrap'>";
|
||||
foreach ($flags as $flag) {
|
||||
$html .= "<span class='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-{$color}-100 text-{$color}-700'>{$flag}</span>";
|
||||
}
|
||||
$html .= "</div>";
|
||||
}
|
||||
|
||||
$html .= "</div>";
|
||||
|
||||
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('<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800">⏳ Waiting for Review</span>'),
|
||||
'confirmed' => new HtmlString('<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">✓ Approved</span>'),
|
||||
'change_requested' => new HtmlString('<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">✗ Changes Requested</span>'),
|
||||
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(
|
||||
'<div class="prose max-w-none">' . ($this->record->appeal?->story ?? '<em>No story provided</em>') . '</div>'
|
||||
))
|
||||
->columnSpanFull(),
|
||||
|
||||
Placeholder::make('appeal_image')
|
||||
->label('Cover Image')
|
||||
->content(function () {
|
||||
$url = $this->record->appeal?->getPictureUrl();
|
||||
if (!$url) return new HtmlString('<em>No image</em>');
|
||||
return new HtmlString(
|
||||
"<img src='{$url}' style='max-width: 400px; border-radius: 8px; border: 1px solid #e5e7eb;' />"
|
||||
);
|
||||
})
|
||||
->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 = '<ul class="space-y-1">';
|
||||
foreach ($changes as $field) {
|
||||
if (is_string($field)) {
|
||||
$label = $friendlyNames[$field] ?? ucfirst(str_replace('_', ' ', $field));
|
||||
$html .= "<li class='text-sm'>• <strong>{$label}</strong> was modified</li>";
|
||||
}
|
||||
}
|
||||
$html .= '</ul>';
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user