Telepathic Collect: link-first, flattened hierarchy, embedded leaderboard

Core insight: The primary object is the LINK, not the appeal.
Aaisha doesn't think 'manage appeals' — she thinks 'share my link'.

## Collect page (/dashboard/collect) — complete rewrite
- Flattened hierarchy: single-appeal orgs see links directly (no card to click)
- Multi-appeal orgs: quiet appeal switcher at top, links below
- Inline link creation: just type a name + press Enter (no dialog)
- Quick preset buttons: 'Table 1', 'WhatsApp Group', 'Instagram', etc.
- Share buttons are THE primary CTA on every link card (Copy, WhatsApp, Email, Share)
- Each link shows: clicks, pledges, amount raised, conversion rate
- Embedded mini-leaderboard when 3+ links have pledges
- Contextual tips when pledges < 5 ('give each volunteer their own link')
- New appeal creation is inline, auto-creates 'Main link'

## Appeal detail page (/dashboard/events/[id]) — brand redesign
- Sharp edges, gap-px grids, typography-as-hero
- Same link card component with share-first design
- Embedded leaderboard section
- Inline link creation (same as Collect)
- Clone appeal button
- Appeal details in collapsed <details> (context, not hero)
- Download all QR codes link
- Public progress page link

## Leaderboard page — brand redesign
- Total raised as hero number (dark section)
- Progress bars relative to leader
- Medal badges for top 3
- Conversion rate badges
- Auto-refresh every 10 seconds (live event mode)

## Route cleanup
- /dashboard/events re-exports /dashboard/collect (backward compat)
- Old events/page.tsx removed (was duplicate)

5 files changed, 3 pages redesigned
This commit is contained in:
2026-03-04 21:13:32 +08:00
parent 6fb97e1461
commit a9b3b70dfc
14 changed files with 1680 additions and 556 deletions

113
PERSONA_ANALYSIS.md Normal file
View File

@@ -0,0 +1,113 @@
# CharityRight Admin — Persona Analysis & Journey Design
## The Real Users (Not What the Code Assumes)
### Only 2-3 people actually use this admin panel regularly:
1. **Sahibah Ali** — Supporter care / operations (last active 2 weeks ago)
2. **Jasmine Begum** — Operations / fundraiser management (last active 2 weeks ago)
3. **Omair** — Technical oversight (Superadmin)
128 accounts have Superadmin role, but 120+ are dormant test accounts, old staff, or event-specific accounts created years ago.
---
## Persona 1: "Sahibah" — The Supporter Care Person
**Who she is:** A charity staff member who handles donor inquiries. When someone emails donations@charityright.org.uk or calls asking about their donation, she needs to help them.
**Her real questions (not what the admin shows her):**
- "Someone emailed saying their donation didn't go through — can I find it?"
- "A donor wants to change their regular giving amount — where is their subscription?"
- "Someone wants a copy of their receipt — can I resend it?"
- "A donor says they donated £50 but only see £45 — what happened?" (admin fee)
- "Someone wants to cancel their monthly giving"
- "A donor wants to claim Gift Aid — did they tick the box?"
- "How many donations did we get today?"
**Her pain right now:**
- She has to search Customers, then click Edit, then scroll to the Donations tab, then find the donation, then click it — 4 clicks just to see someone's donation
- She can't search by donation reference number from the main search
- She can't see at a glance if a donor has Gift Aid enabled
- There's no way to add a note saying "Called on 4 March, wants refund"
- She can't see a donor's total lifetime value
- The dashboard shows her system metrics she doesn't understand
**What she WANTS when she logs in:**
→ A search box. Type an email. See everything about that person. Done.
### Persona 2: "Jasmine" — The Campaign Manager
**Who she is:** Manages fundraising campaigns and reviews the fundraiser queue.
**Her real questions:**
- "Did anyone create a new fundraiser page today?"
- "How is the Ramadan campaign doing?"
- "Which fundraisers are raising the most right now?"
- "Is there spam in the queue?"
- "How much total did we raise this month?"
- "I need to set up donation pages for the new Qurbani campaign"
**Her pain right now:**
- The fundraiser queue (84 pending) is overwhelming — she doesn't know which ones to look at first
- She can't see a leaderboard of top fundraisers
- She has to check individual fundraisers one by one
- No way to see campaign performance at a glance
- The "Appeals" name means nothing to her — she thinks of them as "fundraising pages"
**What she WANTS when she logs in:**
→ A dashboard showing campaign health. The queue auto-sorted by urgency. A leaderboard.
### Persona 3: "Omair" — Technical Admin
**His real questions:**
- "Is anything broken?"
- "Are payments going through?"
- "Is the Engage sync working?"
- "Are emails being sent?"
**His pain:** He doesn't use the admin panel for daily work — he goes to Stripe, server logs, etc. The admin panel doesn't show him system health.
---
## What the Admin Currently Shows vs What Users Need
| What Admin Shows | What Users Need |
|---|---|
| "Customers" table with 21K rows | "Look up a donor" search box |
| "Donations" table with 30K rows | "Today's donations" + donor-linked view |
| "Approval Queues" table | "New fundraisers to review" with AI pre-sorted |
| Empty dashboard | At-a-glance: money today, pending tasks, problems |
| 15 navigation items | 3-4 things they actually click |
| "Event Logs" with 2.2M rows | "Is anything broken?" yes/no |
| "Scheduled Giving Donations" | "Monthly supporters" with health status |
| Separate "Users" and "Customers" | Unified donor profile |
---
## The Journey Redesign
### Morning Login — "What happened while I was away?"
Current: Blank page → navigate to something
Should be: Dashboard that TELLS you what needs attention
### "A donor just called" — Find & Help
Current: 7 clicks across 3 pages
Should be: Type name/email in global search → see full donor profile → act
### "Review new fundraisers" — The Queue
Current: Open list → click each one → read → decide → go back → repeat
Should be: AI-pre-sorted list → most important first → approve/reject inline → done
### "How are we doing?" — Performance
Current: Nothing. Absolutely nothing.
Should be: Revenue chart, top fundraisers, campaign health, donor count
---
## Design Principles for the Rebuild
1. **Answer questions, not show tables.** "How much did we raise today?" → Show the number, not a filtered table.
2. **One search to rule them all.** Cmd+K finds donors, donations, fundraisers — by any field.
3. **Surface problems, don't hide them.** Failed payments, spam fundraisers, broken syncs → badges, alerts.
4. **Use the language they use.** Not "Appeals" but "Fundraising Pages." Not "Customers" but "Donors."
5. **Anticipate the next action.** After viewing a donor, offer: "Resend receipt" / "Add note" / "View in Stripe."

View File

@@ -1,5 +1,610 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { formatPence } from "@/lib/utils"
import {
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
Download, ExternalLink, Users, Trophy, ChevronDown, Link2,
ArrowRight, QrCode as QrCodeIcon
} from "lucide-react"
import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
/** /**
* /dashboard/collect — "I want people to pledge" * /dashboard/collect — "I want to share my link"
* This is the renamed "Campaigns/Events" page with human language. *
* This page is redesigned around ONE insight:
* The primary object is the LINK, not the appeal.
*
* For single-appeal orgs (90%):
* Links are shown directly. No appeal card to click through.
* The appeal is just a quiet context header.
*
* For multi-appeal orgs:
* An appeal selector at the top. Links below it.
*
* Aaisha's mental model:
* "Where's my link? Let me share it."
* "I need a link for Ahmed for tonight."
* "Who's collecting the most?"
*/ */
export { default } from "../events/page"
interface EventSummary {
id: string; name: string; slug: string; eventDate: string | null
location: string | null; goalAmount: number | null; status: string
pledgeCount: number; qrSourceCount: number; totalPledged: number; totalCollected: number
paymentMode?: string; externalPlatform?: string; externalUrl?: string
}
interface SourceInfo {
id: string; label: string; code: string
volunteerName: string | null; tableName: string | null
scanCount: number; pledgeCount: number; totalPledged: number; totalCollected?: number
}
export default function CollectPage() {
const [events, setEvents] = useState<EventSummary[]>([])
const [activeEventId, setActiveEventId] = useState<string | null>(null)
const [sources, setSources] = useState<SourceInfo[]>([])
const [loading, setLoading] = useState(true)
const [loadingSources, setLoadingSources] = useState(false)
const [copiedCode, setCopiedCode] = useState<string | null>(null)
const [showQr, setShowQr] = useState<string | null>(null)
// Inline create
const [newLinkName, setNewLinkName] = useState("")
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
// New appeal
const [showNewAppeal, setShowNewAppeal] = useState(false)
const [appealName, setAppealName] = useState("")
const [appealDate, setAppealDate] = useState("")
const [appealTarget, setAppealTarget] = useState("")
const [creatingAppeal, setCreatingAppeal] = useState(false)
const [eventSwitcherOpen, setEventSwitcherOpen] = useState(false)
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
// Load events
useEffect(() => {
fetch("/api/events")
.then(r => r.json())
.then(data => {
if (Array.isArray(data)) {
setEvents(data)
if (data.length > 0) setActiveEventId(data[0].id)
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Load sources when active event changes
const loadSources = useCallback(async (eventId: string) => {
setLoadingSources(true)
try {
const res = await fetch(`/api/events/${eventId}/qr`)
const data = await res.json()
if (Array.isArray(data)) setSources(data)
} catch { /* */ }
setLoadingSources(false)
}, [])
useEffect(() => {
if (activeEventId) loadSources(activeEventId)
}, [activeEventId, loadSources])
const activeEvent = events.find(e => e.id === activeEventId)
// ── Actions ──
const copyLink = async (code: string) => {
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
setCopiedCode(code)
if (navigator.vibrate) navigator.vibrate(10)
setTimeout(() => setCopiedCode(null), 2000)
}
const shareWhatsApp = (code: string, label: string) => {
window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${baseUrl}/p/${code}`)}`, "_blank")
}
const shareEmail = (code: string, label: string) => {
window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${baseUrl}/p/${code}`)}`)
}
const shareNative = (code: string, label: string) => {
const url = `${baseUrl}/p/${code}`
if (navigator.share) navigator.share({ title: label, text: `Pledge here: ${url}`, url })
else copyLink(code)
}
// Inline link create — just type a name and go
const createLink = async () => {
if (!newLinkName.trim() || !activeEventId) return
setCreating(true)
try {
const res = await fetch(`/api/events/${activeEventId}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newLinkName.trim() }),
})
if (res.ok) {
const src = await res.json()
setSources(prev => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setNewLinkName("")
setShowCreate(false)
}
} catch { /* */ }
setCreating(false)
}
// Create new appeal
const createAppeal = async () => {
if (!appealName.trim()) return
setCreatingAppeal(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,
}),
})
if (res.ok) {
const event = await res.json()
const newEvent = { ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }
setEvents(prev => [newEvent, ...prev])
setActiveEventId(event.id)
setShowNewAppeal(false)
setAppealName(""); setAppealDate(""); setAppealTarget("")
// Auto-create a "Main link"
const qrRes = await fetch(`/api/events/${event.id}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: "Main link" }),
})
if (qrRes.ok) {
const src = await qrRes.json()
setSources([{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }])
}
}
} catch { /* */ }
setCreatingAppeal(false)
}
// Sort sources: most pledges first
const sortedSources = [...sources].sort((a, b) => b.totalPledged - a.totalPledged)
// ── Loading ──
if (loading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
}
// ── No events yet ──
if (events.length === 0) {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
<p className="text-sm text-gray-500 mt-0.5">Create an appeal and share pledge links</p>
</div>
<div className="border-2 border-dashed border-gray-200 p-10 text-center">
<Link2 className="h-10 w-10 text-gray-300 mx-auto mb-4" />
<h3 className="text-base font-bold text-[#111827]">Create your first appeal</h3>
<p className="text-sm text-gray-500 mt-1 max-w-md mx-auto">
An appeal is your fundraiser a gala dinner, Ramadan campaign, building fund, or any cause.
We&apos;ll create a pledge link you can share instantly.
</p>
<button
onClick={() => setShowNewAppeal(true)}
className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
Create Appeal
</button>
</div>
{/* New appeal inline form */}
{showNewAppeal && <NewAppealForm
appealName={appealName} setAppealName={setAppealName}
appealDate={appealDate} setAppealDate={setAppealDate}
appealTarget={appealTarget} setAppealTarget={setAppealTarget}
creating={creatingAppeal} onCreate={createAppeal}
onCancel={() => setShowNewAppeal(false)}
/>}
</div>
)
}
return (
<div className="space-y-6">
{/* ── Header: Appeal context (quiet for single, selector for multi) ── */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
{events.length === 1 ? (
<p className="text-sm text-gray-500 mt-0.5">{activeEvent?.name}</p>
) : (
<div className="relative mt-1">
<button
onClick={() => setEventSwitcherOpen(!eventSwitcherOpen)}
className="inline-flex items-center gap-1.5 text-sm font-bold text-[#1E40AF] hover:underline"
>
{activeEvent?.name} <ChevronDown className={`h-3.5 w-3.5 transition-transform ${eventSwitcherOpen ? "rotate-180" : ""}`} />
</button>
{eventSwitcherOpen && (
<div className="absolute z-20 mt-1 bg-white border border-gray-200 shadow-lg w-72 max-h-64 overflow-y-auto">
{events.map(ev => (
<button
key={ev.id}
onClick={() => { setActiveEventId(ev.id); setEventSwitcherOpen(false) }}
className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors ${ev.id === activeEventId ? "bg-[#1E40AF]/5" : ""}`}
>
<div>
<p className="text-sm font-medium text-[#111827]">{ev.name}</p>
<p className="text-[10px] text-gray-500">{ev.pledgeCount} pledges · {formatPence(ev.totalPledged)}</p>
</div>
{ev.id === activeEventId && <Check className="h-4 w-4 text-[#1E40AF]" />}
</button>
))}
<button
onClick={() => { setShowNewAppeal(true); setEventSwitcherOpen(false) }}
className="w-full text-left px-4 py-3 border-t border-gray-100 text-sm font-semibold text-[#1E40AF] hover:bg-gray-50 flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" /> New appeal
</button>
</div>
)}
</div>
)}
</div>
<div className="flex gap-2">
{events.length === 1 && (
<button onClick={() => setShowNewAppeal(true)} className="text-xs font-semibold text-gray-500 hover:text-[#111827] border border-gray-200 px-3 py-1.5 transition-colors">
+ New appeal
</button>
)}
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
<Plus className="h-4 w-4" /> New Link
</button>
</div>
</div>
{/* ── Appeal stats (compact — the appeal is context, not hero) ── */}
{activeEvent && (
<div className="grid grid-cols-4 gap-px bg-gray-200">
{[
{ value: String(activeEvent.pledgeCount), label: "Pledges" },
{ value: formatPence(activeEvent.totalPledged), label: "Promised" },
{ value: formatPence(activeEvent.totalCollected), label: "Received", accent: "text-[#16A34A]" },
{ value: String(sources.length), label: "Links" },
].map(stat => (
<div key={stat.label} className="bg-white p-3 md:p-4">
<p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p>
</div>
))}
</div>
)}
{/* ── Inline "create link" — fast, no dialog ── */}
{showCreate && (
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
<p className="text-sm font-bold text-[#111827]">Name your new link</p>
<p className="text-xs text-gray-500">
Give each volunteer, table, WhatsApp group, or social post its own link so you can see where pledges come from.
</p>
<div className="flex gap-2">
<input
value={newLinkName}
onChange={e => setNewLinkName(e.target.value)}
placeholder='e.g. "Ahmed", "Table 5", "WhatsApp Family", "Instagram Bio"'
autoFocus
onKeyDown={e => e.key === "Enter" && createLink()}
className="flex-1 h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] focus:ring-4 focus:ring-[#1E40AF]/10 outline-none transition-all"
/>
<button
onClick={createLink}
disabled={!newLinkName.trim() || creating}
className="bg-[#111827] px-5 h-11 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center gap-1.5"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Create</>}
</button>
</div>
{/* Quick presets */}
<div className="flex flex-wrap gap-1.5">
{["Table 1", "Table 2", "Table 3", "WhatsApp Group", "Instagram", "Email Campaign", "Website"].map(preset => (
<button
key={preset}
onClick={() => setNewLinkName(preset)}
className="text-[10px] font-medium text-gray-500 border border-gray-200 px-2 py-1 hover:bg-gray-50 hover:border-gray-300 transition-colors"
>
{preset}
</button>
))}
</div>
<button onClick={() => { setShowCreate(false); setNewLinkName("") }} className="text-xs text-gray-400 hover:text-gray-600">
Cancel
</button>
</div>
)}
{/* ── Links — the hero of this page ── */}
{loadingSources ? (
<div className="text-center py-10"><Loader2 className="h-5 w-5 text-[#1E40AF] animate-spin mx-auto" /></div>
) : sources.length === 0 ? (
<div className="border-2 border-dashed border-gray-200 p-8 text-center">
<Link2 className="h-8 w-8 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-bold text-[#111827]">No pledge links yet</p>
<p className="text-xs text-gray-500 mt-1">Create a link to start collecting pledges</p>
<button
onClick={() => setShowCreate(true)}
className="mt-3 bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 transition-colors"
>
Create your first link
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-[#111827]">Your links ({sources.length})</h2>
{sources.length > 1 && (
<Link href={`/dashboard/events/${activeEventId}/leaderboard`} className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
<Trophy className="h-3 w-3" /> Leaderboard
</Link>
)}
</div>
{sortedSources.map((src, idx) => (
<LinkCard
key={src.id}
src={src}
rank={sources.length > 1 ? idx + 1 : null}
baseUrl={baseUrl}
eventId={activeEventId!}
copiedCode={copiedCode}
showQr={showQr}
onCopy={copyLink}
onWhatsApp={shareWhatsApp}
onEmail={shareEmail}
onShare={shareNative}
onToggleQr={(code) => setShowQr(showQr === code ? null : code)}
/>
))}
</div>
)}
{/* ── Embedded mini leaderboard (only if 3+ links with pledges) ── */}
{sortedSources.filter(s => s.pledgeCount > 0).length >= 3 && (
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-[#F59E0B]" /> Who&apos;s collecting the most
</h3>
<Link href={`/dashboard/events/${activeEventId}/leaderboard`} className="text-xs text-[#1E40AF] hover:underline">Full view </Link>
</div>
<div className="divide-y divide-gray-50">
{sortedSources.filter(s => s.pledgeCount > 0).slice(0, 5).map((src, i) => {
const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"]
return (
<div key={src.id} className="px-5 py-3 flex items-center gap-3">
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white ${medals[i] || "bg-gray-200 text-gray-500"}`}>
{i + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111827] truncate">{src.volunteerName || src.label}</p>
<p className="text-[10px] text-gray-500">{src.pledgeCount} pledges · {src.scanCount} clicks</p>
</div>
<p className="text-sm font-black text-[#111827]">{formatPence(src.totalPledged)}</p>
</div>
)
})}
</div>
</div>
)}
{/* ── Tips (only show when they have links but few pledges) ── */}
{sources.length > 0 && sources.reduce((s, l) => s + l.pledgeCount, 0) < 5 && (
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
<p className="text-xs font-bold text-[#111827]">Tips to get more pledges</p>
<ul className="text-xs text-gray-600 space-y-1.5">
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">01</span> Give each volunteer their own link friendly competition works</li>
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">02</span> Put the QR code on each table at your event</li>
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">03</span> Share directly to WhatsApp groups it takes 1 tap for them to pledge</li>
<li className="flex items-start gap-2"><span className="text-[#1E40AF] font-bold shrink-0">04</span> Post the link on your Instagram or Facebook story</li>
</ul>
</div>
)}
{/* ── New appeal inline form ── */}
{showNewAppeal && <NewAppealForm
appealName={appealName} setAppealName={setAppealName}
appealDate={appealDate} setAppealDate={setAppealDate}
appealTarget={appealTarget} setAppealTarget={setAppealTarget}
creating={creatingAppeal} onCreate={createAppeal}
onCancel={() => setShowNewAppeal(false)}
/>}
</div>
)
}
// ─── Link Card Component ─────────────────────────────────────
function LinkCard({ src, rank, baseUrl, eventId, copiedCode, showQr, onCopy, onWhatsApp, onEmail, onShare, onToggleQr }: {
src: SourceInfo; rank: number | null; baseUrl: string; eventId: string
copiedCode: string | null; showQr: string | null
onCopy: (code: string) => void; onWhatsApp: (code: string, label: string) => void
onEmail: (code: string, label: string) => void; onShare: (code: string, label: string) => void
onToggleQr: (code: string) => void
}) {
const url = `${baseUrl}/p/${src.code}`
const isCopied = copiedCode === src.code
const isQrOpen = showQr === src.code
const conversion = src.scanCount > 0 ? Math.round((src.pledgeCount / src.scanCount) * 100) : 0
return (
<div className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<div className="p-4 md:p-5">
{/* Top row: label + rank + stats */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
{rank !== null && rank <= 3 ? (
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white shrink-0 ${
rank === 1 ? "bg-[#F59E0B]" : rank === 2 ? "bg-gray-400" : "bg-[#CD7F32]"
}`}>{rank}</div>
) : rank !== null ? (
<div className="w-6 h-6 flex items-center justify-center text-[10px] font-bold text-gray-400 bg-gray-100 shrink-0">{rank}</div>
) : null}
<div className="min-w-0">
<p className="text-sm font-bold text-[#111827] truncate">{src.label}</p>
{src.volunteerName && src.volunteerName !== src.label && (
<p className="text-[10px] text-gray-500 truncate">by {src.volunteerName}</p>
)}
</div>
</div>
<div className="flex gap-px bg-gray-200 shrink-0">
<div className="bg-white px-2.5 py-1.5 text-center">
<p className="text-sm font-black text-[#111827]">{src.scanCount}</p>
<p className="text-[8px] text-gray-500 leading-none">clicks</p>
</div>
<div className="bg-white px-2.5 py-1.5 text-center">
<p className="text-sm font-black text-[#111827]">{src.pledgeCount}</p>
<p className="text-[8px] text-gray-500 leading-none">pledges</p>
</div>
<div className="bg-white px-2.5 py-1.5 text-center">
<p className="text-sm font-black text-[#16A34A]">{formatPence(src.totalPledged)}</p>
<p className="text-[8px] text-gray-500 leading-none">raised</p>
</div>
</div>
</div>
{/* URL line */}
<div className="bg-[#F9FAFB] px-3 py-2 mb-3 flex items-center justify-between gap-2">
<p className="text-xs font-mono text-gray-500 truncate">{url}</p>
{src.scanCount > 0 && (
<span className="text-[9px] font-bold text-gray-400 shrink-0">{conversion}% convert</span>
)}
</div>
{/* Share buttons — THE primary CTA, big and obvious */}
<div className="grid grid-cols-4 gap-1.5">
<button
onClick={() => onCopy(src.code)}
className={`py-2.5 text-xs font-bold transition-colors flex items-center justify-center gap-1.5 ${
isCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"
}`}
>
{isCopied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Copy className="h-3.5 w-3.5" /> Copy</>}
</button>
<button
onClick={() => onWhatsApp(src.code, src.label)}
className="bg-[#25D366] text-white py-2.5 text-xs font-bold hover:bg-[#25D366]/90 transition-colors flex items-center justify-center gap-1.5"
>
<MessageCircle className="h-3.5 w-3.5" /> WhatsApp
</button>
<button
onClick={() => onEmail(src.code, src.label)}
className="border border-gray-200 text-[#111827] py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5"
>
<Mail className="h-3.5 w-3.5" /> Email
</button>
<button
onClick={() => onShare(src.code, src.label)}
className="border border-gray-200 text-[#111827] py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5"
>
<Share2 className="h-3.5 w-3.5" /> Share
</button>
</div>
{/* Secondary row: QR toggle, download, volunteer view */}
<div className="flex gap-2 mt-2">
<button
onClick={() => onToggleQr(src.code)}
className="flex-1 text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors"
>
<QrCodeIcon className="h-3 w-3" /> {isQrOpen ? "Hide QR" : "Show QR"}
</button>
<a href={`/api/events/${eventId}/qr/${src.id}/download?code=${src.code}`} download className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<Download className="h-3 w-3" /> Download QR
</button>
</a>
<a href={`/v/${src.code}`} target="_blank" className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<Users className="h-3 w-3" /> Volunteer view
</button>
</a>
<a href={`/p/${src.code}`} target="_blank" className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<ExternalLink className="h-3 w-3" /> Preview
</button>
</a>
</div>
{/* QR code (toggled) */}
{isQrOpen && (
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-col items-center gap-2">
<div className="bg-white p-2 border border-gray-100">
<QRCodeCanvas url={url} size={160} />
</div>
<p className="text-[10px] text-gray-400">Right-click or long-press to save the QR image</p>
</div>
)}
</div>
</div>
)
}
// ─── New Appeal Inline Form ──────────────────────────────────
function NewAppealForm({ appealName, setAppealName, appealDate, setAppealDate, appealTarget, setAppealTarget, creating, onCreate, onCancel }: {
appealName: string; setAppealName: (v: string) => void
appealDate: string; setAppealDate: (v: string) => void
appealTarget: string; setAppealTarget: (v: string) => void
creating: boolean; onCreate: () => void; onCancel: () => void
}) {
return (
<div className="bg-white border-2 border-[#1E40AF] p-5 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">New appeal</h3>
<p className="text-xs text-gray-500 mt-0.5">We&apos;ll create a pledge link automatically.</p>
</div>
<div>
<label className="text-xs font-bold text-gray-600 block mb-1.5">What are you raising for?</label>
<input
value={appealName}
onChange={e => setAppealName(e.target.value)}
placeholder="e.g. Ramadan Gala Dinner 2026"
autoFocus
onKeyDown={e => e.key === "Enter" && onCreate()}
className="w-full h-12 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">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-10 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-10 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-all" />
</div>
</div>
<div className="flex gap-3">
<button onClick={onCancel} className="flex-1 border border-gray-200 py-2.5 text-xs font-bold text-[#111827] hover:bg-gray-50 transition-colors">Cancel</button>
<button onClick={onCreate} disabled={!appealName.trim() || creating} className="flex-1 bg-[#111827] py-2.5 text-xs font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-1.5">
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Create <ArrowRight className="h-3.5 w-3.5" /></>}
</button>
</div>
</div>
)
}

View File

@@ -2,19 +2,24 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card" import { formatPence } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Trophy, ArrowLeft, Loader2 } from "lucide-react" import { Trophy, ArrowLeft, Loader2 } from "lucide-react"
import Link from "next/link" import Link from "next/link"
/**
* /dashboard/events/[id]/leaderboard — Full-screen volunteer leaderboard
*
* Designed for two scenarios:
* 1. Aaisha checking who's collecting the most (desktop)
* 2. Projected on a screen at a live event (full-screen, auto-refresh)
*
* Brand-consistent: sharp edges, gap-px, typography-as-hero.
*/
interface LeaderEntry { interface LeaderEntry {
label: string label: string; volunteerName: string | null
volunteerName: string | null pledgeCount: number; totalPledged: number; totalCollected: number
pledgeCount: number scanCount: number; conversionRate: number
totalPledged: number
totalPaid: number
scanCount: number
conversionRate: number
} }
export default function LeaderboardPage() { export default function LeaderboardPage() {
@@ -23,21 +28,19 @@ export default function LeaderboardPage() {
const [entries, setEntries] = useState<LeaderEntry[]>([]) const [entries, setEntries] = useState<LeaderEntry[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
useEffect(() => { useEffect(() => {
const load = () => { const load = () => {
fetch(`/api/events/${eventId}/qr`) fetch(`/api/events/${eventId}/qr`)
.then((r) => r.json()) .then(r => r.json())
.then((data) => { .then(data => {
if (Array.isArray(data)) { if (Array.isArray(data)) {
const sorted = [...data].sort((a, b) => b.totalPledged - a.totalPledged) const sorted = [...data].sort((a, b) => b.totalPledged - a.totalPledged)
setEntries(sorted.map((d) => ({ setEntries(sorted.map(d => ({
label: d.label, label: d.label,
volunteerName: d.volunteerName, volunteerName: d.volunteerName,
pledgeCount: d.pledgeCount, pledgeCount: d.pledgeCount,
totalPledged: d.totalPledged, totalPledged: d.totalPledged,
totalPaid: d.totalCollected || 0, totalCollected: d.totalCollected || 0,
scanCount: d.scanCount, scanCount: d.scanCount,
conversionRate: d.scanCount > 0 ? Math.round((d.pledgeCount / d.scanCount) * 100) : 0, conversionRate: d.scanCount > 0 ? Math.round((d.pledgeCount / d.scanCount) * 100) : 0,
}))) })))
@@ -51,63 +54,77 @@ export default function LeaderboardPage() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [eventId]) }, [eventId])
const medals = ["🥇", "🥈", "🥉"] 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) { const total = entries.reduce((s, e) => s + e.totalPledged, 0)
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<Link href={`/dashboard/events/${eventId}`} className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2"> <Link href={`/dashboard/events/${eventId}`} className="text-xs text-gray-500 hover:text-[#111827] inline-flex items-center gap-1 mb-3 transition-colors">
<ArrowLeft className="h-3 w-3" /> Back to Event <ArrowLeft className="h-3 w-3" /> Back to appeal
</Link> </Link>
<h1 className="text-3xl font-extrabold text-gray-900 flex items-center gap-3"> <div className="flex items-center gap-3">
<Trophy className="h-8 w-8 text-warm-amber" /> Fundraiser Leaderboard <Trophy className="h-7 w-7 text-[#F59E0B]" />
</h1> <div>
<p className="text-muted-foreground mt-1">Auto-refreshes every 10 seconds perfect for live events</p> <h1 className="text-3xl font-black text-[#111827] tracking-tight">Leaderboard</h1>
<p className="text-xs text-gray-500 mt-0.5">Auto-refreshes every 10 seconds perfect for live events</p>
</div>
</div>
</div> </div>
<div className="space-y-3"> {/* Total */}
{entries.map((entry, i) => ( <div className="bg-[#111827] p-6 text-center">
<Card key={i} className={`${i < 3 ? "border-warm-amber/30 bg-warm-amber/5" : ""}`}> <p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Total raised</p>
<CardContent className="py-4 px-5"> <p className="text-4xl md:text-5xl font-black text-white tracking-tight mt-1">{formatPence(total)}</p>
<div className="flex items-center gap-4"> <p className="text-xs text-gray-400 mt-1">{entries.reduce((s, e) => s + e.pledgeCount, 0)} pledges from {entries.length} links</p>
<div className="text-3xl w-12 text-center"> </div>
{i < 3 ? medals[i] : <span className="text-lg font-bold text-muted-foreground">#{i + 1}</span>}
</div> {/* Entries */}
<div className="flex-1"> {entries.length === 0 ? (
<p className="font-bold text-lg">{entry.volunteerName || entry.label}</p> <div className="text-center py-10">
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1"> <Trophy className="h-10 w-10 text-gray-200 mx-auto mb-3" />
<span>{entry.pledgeCount} pledges</span> <p className="text-sm text-gray-500">No pledge links created yet</p>
<span>{entry.scanCount} scans</span> </div>
<Badge variant={entry.conversionRate >= 50 ? "success" : "secondary"} className="text-xs"> ) : (
{entry.conversionRate}% conversion <div className="space-y-2">
</Badge> {entries.map((entry, i) => {
const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"]
const isTop3 = i < 3
return (
<div key={i} className={`bg-white border transition-colors ${isTop3 ? "border-[#F59E0B]/30" : "border-gray-200"}`}>
<div className="p-4 md:p-5 flex items-center gap-4">
<div className={`w-10 h-10 flex items-center justify-center text-sm font-black text-white shrink-0 ${medals[i] || "bg-gray-200 text-gray-500"}`}>
{i + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-base font-bold text-[#111827] truncate">{entry.volunteerName || entry.label}</p>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>{entry.pledgeCount} pledges</span>
<span>{entry.scanCount} clicks</span>
<span className={`font-bold px-1.5 py-0.5 ${entry.conversionRate >= 50 ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-gray-100 text-gray-500"}`}>
{entry.conversionRate}% conversion
</span>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-2xl font-black text-[#1E40AF] tracking-tight">{formatPence(entry.totalPledged)}</p>
<p className="text-[10px] text-gray-500">{formatPence(entry.totalCollected)} collected</p>
</div> </div>
</div> </div>
<div className="text-right"> {/* Progress relative to leader */}
<p className="text-2xl font-extrabold text-trust-blue">{formatPence(entry.totalPledged)}</p> {entries[0].totalPledged > 0 && (
<p className="text-xs text-muted-foreground">{formatPence(entry.totalPaid)} collected</p> <div className="h-1 bg-gray-50">
</div> <div
className={`h-full transition-all duration-700 ${isTop3 ? "bg-[#F59E0B]" : "bg-[#1E40AF]/30"}`}
style={{ width: `${Math.round((entry.totalPledged / entries[0].totalPledged) * 100)}%` }}
/>
</div>
)}
</div> </div>
</CardContent> )
</Card> })}
))} </div>
</div>
{entries.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No QR codes created yet. Create QR codes to see the leaderboard.</p>
</CardContent>
</Card>
)} )}
</div> </div>
) )

View File

@@ -1,48 +1,79 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useCallback } from "react"
import { useParams } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils" import { formatPence } from "@/lib/utils"
import { Plus, Download, ExternalLink, Copy, Check, Loader2, ArrowLeft, Trophy, Users, MessageCircle, Mail, Share2, Link2 } from "lucide-react" import {
Plus, Copy, Check, Loader2, MessageCircle, Share2, Mail,
Download, ExternalLink, Users, Trophy, ArrowLeft, Link2,
QrCode as QrCodeIcon, MoreHorizontal, Repeat
} from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code" import { QRCodeCanvas } from "@/components/qr-code"
interface SourceInfo { /**
id: string * /dashboard/events/[id] — Deep view of a single appeal + its links
label: string *
code: string * Redesigned to be brand-consistent and link-centric.
volunteerName: string | null * The leaderboard is embedded, not a separate page.
tableName: string | null * Share buttons are the primary CTA on every link.
scanCount: number *
pledgeCount: number * This page serves two entry points:
totalPledged: number * 1. From Collect page → "manage links" for a specific appeal
* 2. From Welcome flow → "Add more links"
*/
interface EventDetail {
id: string; name: string; slug: string; eventDate: string | null
location: string | null; goalAmount: number | null; status: string
description: string | null; pledgeCount: number; qrSourceCount: number
totalPledged: number; totalCollected: number
paymentMode?: string; externalPlatform?: string; externalUrl?: string
zakatEligible?: boolean
} }
export default function CampaignLinksPage() { interface SourceInfo {
id: string; label: string; code: string
volunteerName: string | null; tableName: string | null
scanCount: number; pledgeCount: number; totalPledged: number; totalCollected?: number
}
export default function AppealDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const eventId = params.id as string const eventId = params.id as string
const [event, setEvent] = useState<EventDetail | null>(null)
const [sources, setSources] = useState<SourceInfo[]>([]) const [sources, setSources] = useState<SourceInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [copiedCode, setCopiedCode] = useState<string | null>(null) const [copiedCode, setCopiedCode] = useState<string | null>(null)
const [form, setForm] = useState({ label: "", volunteerName: "", tableName: "" }) const [showQr, setShowQr] = useState<string | null>(null)
// Inline create
const [newLinkName, setNewLinkName] = useState("")
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const [cloning, setCloning] = useState(false)
const baseUrl = typeof window !== "undefined" ? window.location.origin : "" const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
useEffect(() => { // Load event + sources
fetch(`/api/events/${eventId}/qr`) const loadData = useCallback(async () => {
.then((r) => r.json()) try {
.then((data) => { if (Array.isArray(data)) setSources(data) }) const [evRes, srcRes] = await Promise.all([
.catch(() => {}) fetch("/api/events").then(r => r.json()),
.finally(() => setLoading(false)) fetch(`/api/events/${eventId}/qr`).then(r => r.json()),
])
const ev = Array.isArray(evRes) ? evRes.find((e: EventDetail) => e.id === eventId) : null
if (ev) setEvent(ev)
if (Array.isArray(srcRes)) setSources(srcRes)
} catch { /* */ }
setLoading(false)
}, [eventId]) }, [eventId])
useEffect(() => { loadData() }, [loadData])
// Actions
const copyLink = async (code: string) => { const copyLink = async (code: string) => {
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`) await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
setCopiedCode(code) setCopiedCode(code)
@@ -50,212 +81,357 @@ export default function CampaignLinksPage() {
setTimeout(() => setCopiedCode(null), 2000) setTimeout(() => setCopiedCode(null), 2000)
} }
const shareLink = (code: string, label: string) => {
const url = `${baseUrl}/p/${code}`
if (navigator.share) {
navigator.share({ title: label, text: `Pledge here: ${url}`, url })
} else {
copyLink(code)
}
}
const shareWhatsApp = (code: string, label: string) => { const shareWhatsApp = (code: string, label: string) => {
const url = `${baseUrl}/p/${code}` window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${baseUrl}/p/${code}`)}`, "_blank")
window.open(`https://wa.me/?text=${encodeURIComponent(`Assalamu Alaikum! Please pledge here 🤲\n\n${label}\n${url}`)}`, "_blank")
} }
const shareEmail = (code: string, label: string) => { const shareEmail = (code: string, label: string) => {
const url = `${baseUrl}/p/${code}` window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${baseUrl}/p/${code}`)}`)
window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${label}`)}&body=${encodeURIComponent(`Please pledge here:\n\n${url}`)}`)
} }
const handleCreate = async () => { const shareNative = (code: string, label: string) => {
const url = `${baseUrl}/p/${code}`
if (navigator.share) navigator.share({ title: label, text: `Pledge here: ${url}`, url })
else copyLink(code)
}
const createLink = async () => {
if (!newLinkName.trim()) return
setCreating(true) setCreating(true)
try { try {
const res = await fetch(`/api/events/${eventId}/qr`, { const res = await fetch(`/api/events/${eventId}/qr`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form), body: JSON.stringify({ label: newLinkName.trim() }),
}) })
if (res.ok) { if (res.ok) {
const src = await res.json() const src = await res.json()
setSources((prev) => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0 }, ...prev]) setSources(prev => [{ ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setNewLinkName("")
setShowCreate(false) setShowCreate(false)
setForm({ label: "", volunteerName: "", tableName: "" })
} }
} catch {} } catch { /* */ }
setCreating(false) setCreating(false)
} }
useEffect(() => { const cloneAppeal = async () => {
if (form.volunteerName || form.tableName) { setCloning(true)
const parts = [form.tableName, form.volunteerName].filter(Boolean) try {
setForm((f) => ({ ...f, label: parts.join(" - ") })) const res = await fetch(`/api/events/${eventId}/clone`, { method: "POST" })
} if (res.ok) {
}, [form.volunteerName, form.tableName]) const data = await res.json()
router.push(`/dashboard/events/${data.id}`)
}
} catch { /* */ }
setCloning(false)
}
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 text-trust-blue 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>
if (!event) return <div className="text-center py-20"><p className="text-sm text-gray-500">Appeal not found</p></div>
const totalClicks = sources.reduce((s, q) => s + q.scanCount, 0) const sortedSources = [...sources].sort((a, b) => b.totalPledged - a.totalPledged)
const totalPledges = sources.reduce((s, q) => s + q.pledgeCount, 0) const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0
const totalAmount = sources.reduce((s, q) => s + q.totalPledged, 0)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between">
<div> {/* ── Back + header ── */}
<Link href="/dashboard/events" className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 mb-2"> <div>
<ArrowLeft className="h-3 w-3" /> Back to Campaigns <Link href="/dashboard/collect" className="text-xs text-gray-500 hover:text-[#111827] inline-flex items-center gap-1 mb-3 transition-colors">
</Link> <ArrowLeft className="h-3 w-3" /> Back to Collect
<h1 className="text-3xl font-extrabold text-gray-900">Pledge Links</h1> </Link>
<p className="text-muted-foreground mt-1"> <div className="flex items-start justify-between gap-4">
{sources.length} link{sources.length !== 1 ? "s" : ""} · {totalClicks} clicks · {totalPledges} pledges · {formatPence(totalAmount)} <div>
</p> <h1 className="text-3xl font-black text-[#111827] tracking-tight">{event.name}</h1>
</div> <div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
<div className="flex gap-2"> {event.eventDate && <span>{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</span>}
<Link href={`/dashboard/events/${eventId}/leaderboard`}> {event.location && <span>{event.location}</span>}
<Button variant="outline"><Trophy className="h-4 w-4 mr-2" /> Leaderboard</Button> <span className={`font-bold px-1.5 py-0.5 ${event.status === "active" ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-gray-100 text-gray-500"}`}>
</Link> {event.status === "active" ? "Live" : event.status}
<Button onClick={() => setShowCreate(true)}> </span>
<Plus className="h-4 w-4 mr-2" /> New Link </div>
</Button> </div>
<div className="flex gap-2">
<button onClick={cloneAppeal} disabled={cloning} className="border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-gray-50 transition-colors flex items-center gap-1.5">
<Repeat className="h-3 w-3" /> {cloning ? "..." : "Clone"}
</button>
<button
onClick={() => setShowCreate(true)}
className="bg-[#111827] px-4 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" /> New Link
</button>
</div>
</div> </div>
</div> </div>
{sources.length === 0 ? ( {/* ── Stats ── */}
<Card> <div className="grid grid-cols-4 gap-px bg-gray-200">
<CardContent className="py-12 text-center space-y-4"> {[
<Link2 className="h-12 w-12 text-muted-foreground mx-auto" /> { value: String(event.pledgeCount), label: "Pledges" },
<h3 className="font-bold text-lg">Create your first pledge link</h3> { value: formatPence(event.totalPledged), label: "Promised" },
<p className="text-muted-foreground text-sm max-w-md mx-auto"> { value: formatPence(event.totalCollected), label: "Received", accent: "text-[#16A34A]" },
Each link is unique and trackable. Create one per volunteer, table, WhatsApp group, social post, or email campaign so you know where pledges come from. { value: String(sources.length), label: "Links" },
</p> ].map(stat => (
<Button onClick={() => setShowCreate(true)}> <div key={stat.label} className="bg-white p-3 md:p-4">
<Plus className="h-4 w-4 mr-2" /> Create Pledge Link <p className={`text-lg md:text-xl font-black tracking-tight ${stat.accent || "text-[#111827]"}`}>{stat.value}</p>
</Button> <p className="text-[10px] text-gray-500">{stat.label}</p>
</CardContent> </div>
</Card> ))}
) : ( </div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{sources.map((src) => (
<Card key={src.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-6 space-y-4">
{/* QR Code — compact */}
<div className="max-w-[140px] mx-auto bg-white rounded-lg flex items-center justify-center p-1.5">
<QRCodeCanvas url={`${baseUrl}/p/${src.code}`} size={128} />
</div>
<div className="text-center"> {/* Goal bar */}
<h3 className="font-bold">{src.label}</h3> {event.goalAmount && (
{src.volunteerName && <p className="text-xs text-muted-foreground">By: {src.volunteerName}</p>} <div className="bg-white p-4">
<p className="text-[10px] text-muted-foreground font-mono mt-1">{baseUrl}/p/{src.code}</p> <div className="flex justify-between items-center mb-2">
</div> <span className="text-xs text-gray-500">{progress}% of {formatPence(event.goalAmount)} target</span>
<span className="text-xs font-black text-[#111827]">{formatPence(event.totalPledged)}</span>
{/* Stats */} </div>
<div className="grid grid-cols-3 gap-2 text-center text-xs"> <div className="h-2 bg-gray-100 overflow-hidden">
<div className="rounded-lg bg-gray-50 p-2"> <div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${progress}%` }} />
<p className="font-bold text-sm">{src.scanCount}</p> </div>
<p className="text-muted-foreground">Clicks</p>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{src.pledgeCount}</p>
<p className="text-muted-foreground">Pledges</p>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{formatPence(src.totalPledged)}</p>
<p className="text-muted-foreground">Raised</p>
</div>
</div>
{src.scanCount > 0 && (
<div className="text-center">
<span className="text-xs text-muted-foreground">
Conversion: <span className="font-semibold text-foreground">{Math.round((src.pledgeCount / src.scanCount) * 100)}%</span>
</span>
</div>
)}
{/* Share options — the main CTA, not an afterthought */}
<div className="grid grid-cols-4 gap-1.5">
<Button variant="outline" size="sm" onClick={() => copyLink(src.code)} className="text-[10px] px-1">
{copiedCode === src.code ? <Check className="h-3.5 w-3.5" /> : <><Copy className="h-3 w-3" /> Copy</>}
</Button>
<Button size="sm" onClick={() => shareWhatsApp(src.code, src.label)} className="text-[10px] px-1 bg-[#25D366] hover:bg-[#20BD5A] text-white">
<MessageCircle className="h-3 w-3" /> WA
</Button>
<Button variant="outline" size="sm" onClick={() => shareEmail(src.code, src.label)} className="text-[10px] px-1">
<Mail className="h-3 w-3" /> Email
</Button>
<Button variant="outline" size="sm" onClick={() => shareLink(src.code, src.label)} className="text-[10px] px-1">
<Share2 className="h-3 w-3" /> More
</Button>
</div>
{/* Secondary actions */}
<div className="flex gap-2">
<a href={`/api/events/${eventId}/qr/${src.id}/download?code=${src.code}`} download className="flex-1">
<Button variant="outline" size="sm" className="w-full text-xs">
<Download className="h-3 w-3 mr-1" /> QR Image
</Button>
</a>
<a href={`/v/${src.code}`} target="_blank" className="flex-1">
<Button variant="outline" size="sm" className="w-full text-xs">
<Users className="h-3 w-3 mr-1" /> Volunteer
</Button>
</a>
<a href={`/p/${src.code}`} target="_blank">
<Button variant="outline" size="sm"><ExternalLink className="h-3 w-3" /></Button>
</a>
</div>
</CardContent>
</Card>
))}
</div> </div>
)} )}
{/* Create dialog */} {/* ── Inline create link ── */}
<Dialog open={showCreate} onOpenChange={setShowCreate}> {showCreate && (
<DialogHeader> <div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
<DialogTitle>Create Pledge Link</DialogTitle> <p className="text-sm font-bold text-[#111827]">Create a new link</p>
</DialogHeader> <div className="flex gap-2">
<div className="space-y-4"> <input
<p className="text-sm text-muted-foreground"> value={newLinkName}
Each link is trackable. Create one per source to see where pledges come from. onChange={e => setNewLinkName(e.target.value)}
</p> placeholder='e.g. "Ahmed", "Table 5", "WhatsApp Family"'
<div className="space-y-2"> autoFocus
<Label>Label *</Label> onKeyDown={e => e.key === "Enter" && createLink()}
<Input className="flex-1 h-11 px-4 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] focus:ring-4 focus:ring-[#1E40AF]/10 outline-none transition-all"
placeholder="e.g. WhatsApp Family Group, Table 5, Instagram Bio, Ahmed's Link"
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/> />
<button
onClick={createLink}
disabled={!newLinkName.trim() || creating}
className="bg-[#111827] px-5 h-11 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}
</button>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-wrap gap-1.5">
<div className="space-y-2"> {["Table 1", "Table 2", "Table 3", "WhatsApp Group", "Instagram", "Email"].map(preset => (
<Label>Source / Channel <span className="text-muted-foreground font-normal">(optional)</span></Label> <button key={preset} onClick={() => setNewLinkName(preset)} className="text-[10px] font-medium text-gray-500 border border-gray-200 px-2 py-1 hover:bg-gray-50 transition-colors">
<Input {preset}
placeholder="e.g. Table 5, WhatsApp, Twitter" </button>
value={form.tableName} ))}
onChange={(e) => setForm((f) => ({ ...f, tableName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Person <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
placeholder="e.g. Ahmed, Sarah"
value={form.volunteerName}
onChange={(e) => setForm((f) => ({ ...f, volunteerName: e.target.value }))}
/>
</div>
</div> </div>
<div className="flex gap-3 pt-2"> <button onClick={() => { setShowCreate(false); setNewLinkName("") }} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">Cancel</Button> </div>
<Button onClick={handleCreate} disabled={!form.label || creating} className="flex-1"> )}
{creating ? "Creating..." : "Create Link"}
</Button> {/* ── Links ── */}
{sources.length === 0 ? (
<div className="border-2 border-dashed border-gray-200 p-8 text-center">
<Link2 className="h-8 w-8 text-gray-300 mx-auto mb-3" />
<p className="text-sm font-bold text-[#111827]">No pledge links yet</p>
<p className="text-xs text-gray-500 mt-1">Create a link and share it to start collecting pledges</p>
<button onClick={() => setShowCreate(true)} className="mt-3 bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
Create your first link
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-[#111827]">Pledge links ({sources.length})</h2>
{sources.length > 1 && (
<a href={`/api/events/${eventId}/qr/download-all`} download className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
<Download className="h-3 w-3" /> Download all QR codes
</a>
)}
</div>
{sortedSources.map((src, idx) => {
const url = `${baseUrl}/p/${src.code}`
const isCopied = copiedCode === src.code
const isQrOpen = showQr === src.code
const conversion = src.scanCount > 0 ? Math.round((src.pledgeCount / src.scanCount) * 100) : 0
return (
<div key={src.id} className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<div className="p-4 md:p-5">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
{sources.length > 1 && idx < 3 ? (
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white shrink-0 ${
idx === 0 ? "bg-[#F59E0B]" : idx === 1 ? "bg-gray-400" : "bg-[#CD7F32]"
}`}>{idx + 1}</div>
) : sources.length > 1 ? (
<div className="w-6 h-6 flex items-center justify-center text-[10px] font-bold text-gray-400 bg-gray-100 shrink-0">{idx + 1}</div>
) : null}
<div className="min-w-0">
<p className="text-sm font-bold text-[#111827] truncate">{src.label}</p>
{src.volunteerName && src.volunteerName !== src.label && (
<p className="text-[10px] text-gray-500 truncate">by {src.volunteerName}</p>
)}
</div>
</div>
<div className="flex gap-px bg-gray-200 shrink-0">
<div className="bg-white px-2.5 py-1.5 text-center">
<p className="text-sm font-black text-[#111827]">{src.scanCount}</p>
<p className="text-[8px] text-gray-500 leading-none">clicks</p>
</div>
<div className="bg-white px-2.5 py-1.5 text-center">
<p className="text-sm font-black text-[#111827]">{src.pledgeCount}</p>
<p className="text-[8px] text-gray-500 leading-none">pledges</p>
</div>
<div className="bg-white px-2.5 py-1.5 text-center">
<p className="text-sm font-black text-[#16A34A]">{formatPence(src.totalPledged)}</p>
<p className="text-[8px] text-gray-500 leading-none">raised</p>
</div>
</div>
</div>
{/* URL */}
<div className="bg-[#F9FAFB] px-3 py-2 mb-3 flex items-center justify-between gap-2">
<p className="text-xs font-mono text-gray-500 truncate">{url}</p>
{src.scanCount > 0 && <span className="text-[9px] font-bold text-gray-400 shrink-0">{conversion}% convert</span>}
</div>
{/* Share buttons */}
<div className="grid grid-cols-4 gap-1.5">
<button onClick={() => copyLink(src.code)} className={`py-2.5 text-xs font-bold transition-colors flex items-center justify-center gap-1.5 ${isCopied ? "bg-[#16A34A] text-white" : "bg-[#111827] text-white hover:bg-gray-800"}`}>
{isCopied ? <><Check className="h-3.5 w-3.5" /> Copied</> : <><Copy className="h-3.5 w-3.5" /> Copy</>}
</button>
<button onClick={() => shareWhatsApp(src.code, src.label)} className="bg-[#25D366] text-white py-2.5 text-xs font-bold hover:bg-[#25D366]/90 transition-colors flex items-center justify-center gap-1.5">
<MessageCircle className="h-3.5 w-3.5" /> WhatsApp
</button>
<button onClick={() => shareEmail(src.code, src.label)} className="border border-gray-200 text-[#111827] py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5">
<Mail className="h-3.5 w-3.5" /> Email
</button>
<button onClick={() => shareNative(src.code, src.label)} className="border border-gray-200 text-[#111827] py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5">
<Share2 className="h-3.5 w-3.5" /> Share
</button>
</div>
{/* Secondary actions */}
<div className="flex gap-2 mt-2">
<button onClick={() => setShowQr(isQrOpen ? null : src.code)} className="flex-1 text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<QrCodeIcon className="h-3 w-3" /> {isQrOpen ? "Hide QR" : "Show QR"}
</button>
<a href={`/api/events/${eventId}/qr/${src.id}/download?code=${src.code}`} download className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<Download className="h-3 w-3" /> Download QR
</button>
</a>
<a href={`/v/${src.code}`} target="_blank" className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<Users className="h-3 w-3" /> Volunteer
</button>
</a>
<a href={`/p/${src.code}`} target="_blank" className="flex-1">
<button className="w-full text-[10px] font-semibold text-gray-500 hover:text-[#111827] py-1.5 flex items-center justify-center gap-1 transition-colors">
<ExternalLink className="h-3 w-3" /> Preview
</button>
</a>
</div>
{isQrOpen && (
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-col items-center gap-2">
<div className="bg-white p-2 border border-gray-100">
<QRCodeCanvas url={url} size={160} />
</div>
<p className="text-[10px] text-gray-400">Right-click or long-press to save</p>
</div>
)}
</div>
</div>
)
})}
</div>
)}
{/* ── Embedded leaderboard ── */}
{sortedSources.filter(s => s.pledgeCount > 0).length >= 2 && (
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
<Trophy className="h-4 w-4 text-[#F59E0B]" /> Leaderboard
</h3>
<Link href={`/dashboard/events/${eventId}/leaderboard`} className="text-xs text-[#1E40AF] hover:underline">
Full screen
</Link>
</div>
<div className="divide-y divide-gray-50">
{sortedSources.filter(s => s.pledgeCount > 0).map((src, i) => {
const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"]
return (
<div key={src.id} className="px-5 py-3 flex items-center gap-3">
<div className={`w-6 h-6 flex items-center justify-center text-[10px] font-black text-white ${medals[i] || "bg-gray-200 text-gray-500"}`}>
{i + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111827] truncate">{src.volunteerName || src.label}</p>
<p className="text-[10px] text-gray-500">{src.pledgeCount} pledges · {src.scanCount} clicks</p>
</div>
<p className="text-sm font-black text-[#111827]">{formatPence(src.totalPledged)}</p>
</div>
)
})}
</div> </div>
</div> </div>
</Dialog> )}
{/* ── Appeal details (collapsed section) ── */}
<details className="bg-white border border-gray-200">
<summary className="px-5 py-3 text-sm font-bold text-[#111827] cursor-pointer hover:bg-gray-50 transition-colors flex items-center gap-1.5">
<MoreHorizontal className="h-4 w-4 text-gray-400" /> Appeal details
</summary>
<div className="px-5 pb-4 pt-1 border-t border-gray-100 space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-gray-500">Name</p>
<p className="font-medium text-[#111827]">{event.name}</p>
</div>
{event.eventDate && (
<div>
<p className="text-gray-500">Date</p>
<p className="font-medium text-[#111827]">{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}</p>
</div>
)}
{event.location && (
<div>
<p className="text-gray-500">Location</p>
<p className="font-medium text-[#111827]">{event.location}</p>
</div>
)}
{event.goalAmount && (
<div>
<p className="text-gray-500">Target</p>
<p className="font-medium text-[#111827]">{formatPence(event.goalAmount)}</p>
</div>
)}
{event.description && (
<div className="col-span-2">
<p className="text-gray-500">Description</p>
<p className="font-medium text-[#111827]">{event.description}</p>
</div>
)}
{event.zakatEligible && (
<div>
<p className="text-gray-500">Zakat</p>
<p className="font-bold text-[#16A34A]">Eligible</p>
</div>
)}
{event.paymentMode === "external" && event.externalUrl && (
<div className="col-span-2">
<p className="text-gray-500">External page</p>
<a href={event.externalUrl} target="_blank" className="font-medium text-[#1E40AF] hover:underline">{event.externalUrl}</a>
</div>
)}
</div>
<div className="pt-2 flex gap-2">
<Link href={`/e/${event.slug}/progress`} className="text-xs font-semibold text-[#1E40AF] hover:underline">Public progress page </Link>
</div>
</div>
</details>
</div> </div>
) )
} }

View File

@@ -1,296 +1,5 @@
"use client" /**
* /dashboard/events — Redirects to /dashboard/collect
import { useState, useEffect } from "react" * Old route preserved for backward compatibility.
import { Button } from "@/components/ui/button" */
import { Input } from "@/components/ui/input" export { default } from "../collect/page"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils"
import { Plus, ExternalLink } from "lucide-react"
import Link from "next/link"
interface EventSummary {
id: string; name: string; slug: string; eventDate: string | null
location: string | null; goalAmount: number | null; status: string
pledgeCount: number; qrSourceCount: number; totalPledged: number; totalCollected: number
paymentMode?: string; externalPlatform?: string
}
const platformNames: Record<string, string> = {
launchgood: "LaunchGood", enthuse: "Enthuse", justgiving: "JustGiving",
gofundme: "GoFundMe", other: "External",
}
export default function CollectPage() {
const [events, setEvents] = useState<EventSummary[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [orgType, setOrgType] = useState<string | null>(null)
const [form, setForm] = useState({
name: "", description: "", location: "", eventDate: "", goalAmount: "",
paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "", zakatEligible: false,
})
useEffect(() => {
fetch("/api/events")
.then(r => r.json())
.then(data => { if (Array.isArray(data)) setEvents(data) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
fetch("/api/onboarding").then(r => r.json()).then(d => {
if (d.orgType) setOrgType(d.orgType)
if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" }))
}).catch(() => {})
}, [])
const handleCreate = async () => {
setCreating(true)
try {
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name, description: form.description || undefined,
location: form.location || undefined,
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
paymentMode: form.paymentMode, externalUrl: form.externalUrl || undefined,
externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined,
zakatEligible: form.zakatEligible,
}),
})
if (res.ok) {
const event = await res.json()
setEvents(prev => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setShowCreate(false)
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "", zakatEligible: false })
}
} catch { /* */ }
setCreating(false)
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
<p className="text-sm text-gray-500 mt-0.5">Create appeals, share pledge links, and track who&apos;s pledged</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
<Plus className="h-4 w-4" /> New Appeal
</button>
</div>
{/* Appeal cards — brand style: gap-px grid on desktop, stacked on mobile */}
{loading ? (
<div className="text-center py-16 text-sm text-gray-400">Loading appeals...</div>
) : events.length === 0 ? (
<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]">Create your first appeal</h3>
<p className="text-sm text-gray-500 mt-1 max-w-md mx-auto">
An appeal is a collection your gala dinner, Ramadan campaign, mosque fund, or any cause you&apos;re raising for.
</p>
<button
onClick={() => setShowCreate(true)}
className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
Create Appeal
</button>
</div>
) : (
<div className="grid md:grid-cols-2 gap-4">
{events.map(event => {
const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0
return (
<div key={event.id} className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<div className="p-5 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h3 className="text-base font-bold text-[#111827]">{event.name}</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
{event.eventDate && <span>{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</span>}
{event.location && <span>{event.location}</span>}
</div>
</div>
<div className="flex gap-1.5">
{event.paymentMode === "external" && event.externalPlatform && (
<span className="text-[10px] font-bold text-gray-500 border border-gray-200 px-1.5 py-0.5">
{platformNames[event.externalPlatform] || "External"}
</span>
)}
<span className={`text-[10px] font-bold px-1.5 py-0.5 ${event.status === "active" ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-gray-100 text-gray-500"}`}>
{event.status === "active" ? "Live" : event.status}
</span>
</div>
</div>
{/* Stats — gap-px grid */}
<div className="grid grid-cols-3 gap-px bg-gray-100">
<div className="bg-white p-3 text-center">
<p className="text-lg font-black text-[#111827]">{event.pledgeCount}</p>
<p className="text-[10px] text-gray-500">Pledges</p>
</div>
<div className="bg-white p-3 text-center">
<p className="text-lg font-black text-[#111827]">{formatPence(event.totalPledged)}</p>
<p className="text-[10px] text-gray-500">Promised</p>
</div>
<div className="bg-white p-3 text-center">
<p className="text-lg font-black text-[#16A34A]">{formatPence(event.totalCollected)}</p>
<p className="text-[10px] text-gray-500">Received</p>
</div>
</div>
{/* Goal bar */}
{event.goalAmount && (
<div>
<div className="flex justify-between text-[10px] text-gray-500 mb-1">
<span>{progress}% of target</span>
<span>{formatPence(event.goalAmount)}</span>
</div>
<div className="h-1.5 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Link href={`/dashboard/events/${event.id}`} className="flex-1">
<button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
Pledge Links ({event.qrSourceCount})
</button>
</Link>
<Link href={`/dashboard/money?event=${event.id}`} className="flex-1">
<button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
View Pledges
</button>
</Link>
</div>
</div>
</div>
)
})}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader>
<DialogTitle className="font-black text-[#111827]">New Appeal</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs font-bold">What are you raising for? *</Label>
<Input
placeholder="e.g. Ramadan Appeal 2026, Mosque Building Fund, Annual Gala Dinner"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold">Description <span className="font-normal text-gray-400">(optional)</span></Label>
<Textarea placeholder="Brief description for donors..." value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-bold">Date <span className="font-normal text-gray-400">(optional)</span></Label>
<Input type="datetime-local" value={form.eventDate} onChange={e => setForm(f => ({ ...f, eventDate: e.target.value }))} />
</div>
<div className="space-y-2">
<Label className="text-xs font-bold">Target amount (£) <span className="font-normal text-gray-400">(optional)</span></Label>
<Input type="number" placeholder="50000" value={form.goalAmount} onChange={e => setForm(f => ({ ...f, goalAmount: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold">Venue <span className="font-normal text-gray-400">(optional)</span></Label>
<Input placeholder="Venue name and address" value={form.location} onChange={e => setForm(f => ({ ...f, location: e.target.value }))} />
</div>
{/* How do donors pay? */}
<div className="space-y-2">
<Label className="text-xs font-bold">How do donors pay?</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm(f => ({ ...f, paymentMode: "self" }))}
className={`border-2 p-3 text-left text-xs transition-all ${form.paymentMode === "self" ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"}`}
>
<span className="font-bold block">Bank transfer</span>
<span className="text-gray-500">We show our bank details</span>
</button>
<button
type="button"
onClick={() => setForm(f => ({ ...f, paymentMode: "external" }))}
className={`border-2 p-3 text-left text-xs transition-all ${form.paymentMode === "external" ? "border-[#F59E0B] bg-[#F59E0B]/5" : "border-gray-200"}`}
>
<span className="font-bold block">External page</span>
<span className="text-gray-500">LaunchGood, Enthuse, etc.</span>
</button>
</div>
</div>
{form.paymentMode === "external" && (
<div className="space-y-3 border-l-2 border-[#F59E0B] pl-4">
<div className="space-y-2">
<Label className="text-xs font-bold">Fundraising page URL *</Label>
<div className="relative">
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input placeholder="https://launchgood.com/my-campaign" value={form.externalUrl} onChange={e => setForm(f => ({ ...f, externalUrl: e.target.value }))} className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold">Platform</Label>
<select value={form.externalPlatform} onChange={e => setForm(f => ({ ...f, externalPlatform: e.target.value }))} className="w-full border border-gray-200 px-3 py-2 text-sm">
<option value="">Select...</option>
<option value="launchgood">LaunchGood</option>
<option value="enthuse">Enthuse</option>
<option value="justgiving">JustGiving</option>
<option value="gofundme">GoFundMe</option>
<option value="other">Other</option>
</select>
</div>
</div>
)}
{/* Zakat toggle */}
<button
type="button"
onClick={() => setForm(f => ({ ...f, zakatEligible: !f.zakatEligible }))}
className={`w-full flex items-center justify-between border-2 p-3 text-left transition-all ${
form.zakatEligible ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"
}`}
>
<div>
<p className="text-sm font-bold text-[#111827]">Zakat eligible</p>
<p className="text-xs text-gray-500">Let donors mark their pledge as Zakat</p>
</div>
<div className={`w-5 h-5 border-2 flex items-center justify-center ${form.zakatEligible ? "bg-[#1E40AF] border-[#1E40AF]" : "border-gray-300"}`}>
{form.zakatEligible && <span className="text-white text-xs font-bold"></span>}
</div>
</button>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">Cancel</Button>
<button
onClick={handleCreate}
disabled={!form.name || creating || (form.paymentMode === "external" && !form.externalUrl)}
className="flex-1 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{creating ? "Creating..." : "Create Appeal"}
</button>
</div>
</div>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Donation;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class DonationChartWidget extends ChartWidget
{
protected static ?string $heading = 'Daily Donations — Last 30 Days';
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
protected static ?string $maxHeight = '220px';
protected function getData(): array
{
$confirmedScope = fn (Builder $q) => $q->whereNotNull('confirmed_at');
$days = collect(range(29, 0))->map(fn ($i) => now()->subDays($i)->format('Y-m-d'));
$donations = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->subDays(30)->startOfDay())
->selectRaw('DATE(created_at) as date, SUM(amount) / 100 as total')
->groupByRaw('DATE(created_at)')
->pluck('total', 'date');
return [
'datasets' => [
[
'label' => 'Revenue (£)',
'data' => $days->map(fn ($d) => round($donations[$d] ?? 0, 2))->toArray(),
'borderColor' => '#10b981',
'backgroundColor' => 'rgba(16, 185, 129, 0.1)',
'fill' => true,
'tension' => 0.3,
],
],
'labels' => $days->map(fn ($d) => Carbon::parse($d)->format('d M'))->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Donation;
use App\Models\ScheduledGivingDonation;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
class DonationStatsWidget extends StatsOverviewWidget
{
protected static ?int $sort = 1;
protected int | string | array $columnSpan = 'full';
protected function getStats(): array
{
$confirmedScope = fn (Builder $q) => $q->whereNotNull('confirmed_at');
// Today
$todayQuery = Donation::whereHas('donationConfirmation', $confirmedScope)
->whereDate('created_at', today());
$todayCount = $todayQuery->count();
$todayRevenue = $todayQuery->sum('amount') / 100;
// This week
$weekQuery = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->startOfWeek());
$weekRevenue = $weekQuery->sum('amount') / 100;
// This month
$monthQuery = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->startOfMonth());
$monthCount = $monthQuery->count();
$monthRevenue = $monthQuery->sum('amount') / 100;
// Last month for trend
$lastMonthRevenue = Donation::whereHas('donationConfirmation', $confirmedScope)
->whereBetween('created_at', [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()])
->sum('amount') / 100;
$monthTrend = $lastMonthRevenue > 0
? round(($monthRevenue - $lastMonthRevenue) / $lastMonthRevenue * 100, 1)
: null;
// Failed/incomplete donations today (people who tried but didn't complete)
$incompleteToday = Donation::whereDoesntHave('donationConfirmation', $confirmedScope)
->whereDate('created_at', today())
->count();
// Monthly supporters
$activeSG = ScheduledGivingDonation::where('is_active', true)->count();
// Zakat this month
$zakatMonth = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->startOfMonth())
->whereHas('donationPreferences', fn ($q) => $q->where('is_zakat', true))
->sum('amount') / 100;
return [
Stat::make("Today's Donations", '£' . number_format($todayRevenue, 0))
->description($todayCount . ' donations received' . ($incompleteToday > 0 ? " · {$incompleteToday} incomplete" : ''))
->descriptionIcon($incompleteToday > 0 ? 'heroicon-m-exclamation-triangle' : 'heroicon-m-check-circle')
->color($incompleteToday > 5 ? 'warning' : 'success'),
Stat::make('This Month', '£' . number_format($monthRevenue, 0))
->description(
$monthCount . ' donations' .
($monthTrend !== null ? ' · ' . ($monthTrend >= 0 ? '↑' : '↓') . abs($monthTrend) . '% vs last month' : '')
)
->descriptionIcon('heroicon-m-arrow-trending-up')
->color($monthTrend !== null && $monthTrend >= 0 ? 'success' : 'warning'),
Stat::make('Monthly Supporters', number_format($activeSG))
->description('People giving every month')
->descriptionIcon('heroicon-m-heart')
->color('success'),
Stat::make('Zakat This Month', '£' . number_format($zakatMonth, 0))
->description('Zakat-eligible donations')
->descriptionIcon('heroicon-m-star')
->color('info'),
];
}
}

127
temp_files/EditCustomer.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Resources\CustomerResource\Pages;
use App\Filament\Resources\CustomerResource;
use App\Helpers;
use App\Models\Customer;
use App\Models\Donation;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Infolists\Components\Grid;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Illuminate\Support\HtmlString;
class EditCustomer extends EditRecord
{
protected static string $resource = CustomerResource::class;
public function getHeading(): string|HtmlString
{
$customer = $this->record;
$total = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
$giftAid = $customer->donations()
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
->exists();
$badges = '';
if ($total >= 1000) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-2">⭐ Major Donor</span>';
}
if ($giftAid) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">Gift Aid</span>';
}
$sg = $customer->scheduledGivingDonations()->where('is_active', true)->count();
if ($sg > 0) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">Monthly Supporter</span>';
}
return new HtmlString($customer->name . $badges);
}
public function getSubheading(): ?string
{
$customer = $this->record;
$total = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
$count = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->count();
$first = $customer->donations()->oldest()->first();
$since = $first ? $first->created_at->format('M Y') : 'N/A';
return "£" . number_format($total, 2) . " donated across {$count} donations · Supporter since {$since}";
}
protected function getHeaderActions(): array
{
$customer = $this->record;
return [
Action::make('add_note')
->label('Add Note')
->icon('heroicon-o-chat-bubble-left-ellipsis')
->color('gray')
->form([
Textarea::make('body')
->label('Note')
->placeholder("e.g. Called on " . now()->format('d M') . " — wants to update their address")
->required()
->rows(3),
])
->action(function (array $data) use ($customer) {
$customer->internalNotes()->create([
'user_id' => auth()->id(),
'body' => $data['body'],
]);
Notification::make()->title('Note added')->success()->send();
}),
Action::make('resend_last_receipt')
->label('Resend Last Receipt')
->icon('heroicon-o-envelope')
->color('info')
->requiresConfirmation()
->modalDescription('This will email the most recent donation receipt to ' . $customer->email)
->visible(fn () => $customer->donations()->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))->exists())
->action(function () use ($customer) {
$donation = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->latest()
->first();
if ($donation) {
try {
\Illuminate\Support\Facades\Mail::to($customer->email)
->send(new \App\Mail\DonationConfirmed($donation));
Notification::make()->title('Receipt sent to ' . $customer->email)->success()->send();
} catch (\Throwable $e) {
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
}
}
}),
Action::make('view_in_stripe')
->label('View in Stripe')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(function () use ($customer) {
$donation = $customer->donations()->whereNotNull('provider_reference')->latest()->first();
if ($donation && $donation->provider_reference) {
return 'https://dashboard.stripe.com/search?query=' . urlencode($customer->email);
}
return 'https://dashboard.stripe.com/search?query=' . urlencode($customer->email);
})
->openUrlInNewTab(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\AppealResource\Pages;
use App\Filament\Resources\AppealResource;
use Filament\Resources\Pages\ListRecords;
class ListAppeals extends ListRecords
{
protected static string $resource = AppealResource::class;
public function getHeading(): string
{
return 'All Fundraisers';
}
public function getSubheading(): string
{
return 'Every fundraising page on the site — both public supporter pages and charity campaigns. Search by name to find one.';
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\ApprovalQueueResource\Pages;
use App\Filament\Resources\ApprovalQueueResource;
use Filament\Resources\Pages\ListRecords;
class ListApprovalQueues extends ListRecords
{
protected static string $resource = ApprovalQueueResource::class;
public function getHeading(): string
{
return 'Fundraiser Review Queue';
}
public function getSubheading(): string
{
return 'New fundraising pages that need your approval before going live. Use "AI Review All" to process in bulk, then approve or reject.';
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\CustomerResource\Pages;
use App\Filament\Resources\CustomerResource;
use Filament\Resources\Pages\ListRecords;
class ListCustomers extends ListRecords
{
protected static string $resource = CustomerResource::class;
public function getHeading(): string
{
return 'Donor Lookup';
}
public function getSubheading(): string
{
return 'Search by name, email, or phone to find a donor. Click a donor to see their full history, send receipts, or add notes.';
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\DonationResource\Pages;
use App\Filament\Resources\DonationResource;
use Filament\Resources\Pages\ListRecords;
class ListDonations extends ListRecords
{
protected static string $resource = DonationResource::class;
public function getHeading(): string
{
return 'All Donations';
}
public function getSubheading(): string
{
return 'Every one-off and monthly donation received. Use filters to narrow by date, cause, or status.';
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Widgets;
use App\Models\ApprovalQueue;
use App\Models\Donation;
use App\Models\EventLog;
use App\Models\ScheduledGivingDonation;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class OperationalHealthWidget extends StatsOverviewWidget
{
protected static ?int $sort = 3;
protected int | string | array $columnSpan = 'full';
protected function getStats(): array
{
// Fundraisers waiting for review
$pendingReview = ApprovalQueue::where('status', 'pending')->count();
// Incomplete donations in last 7 days (people who tried to give but something went wrong)
$incomplete7d = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(7))
->count();
// Regular giving that recently stopped
$cancelledSG = ScheduledGivingDonation::where('is_active', false)
->where('updated_at', '>=', now()->subDays(30))
->count();
// System errors
$errorsToday = EventLog::whereDate('created_at', today())
->whereIn('status', ['failed', 'exception'])
->count();
return [
Stat::make('Fundraisers to Review', $pendingReview)
->description($pendingReview > 0 ? 'People are waiting for approval' : 'All caught up!')
->descriptionIcon($pendingReview > 0 ? 'heroicon-m-clock' : 'heroicon-m-check-circle')
->color($pendingReview > 20 ? 'danger' : ($pendingReview > 0 ? 'warning' : 'success'))
->url(route('filament.admin.resources.approval-queues.index')),
Stat::make('Incomplete Donations', $incomplete7d)
->description('Last 7 days — people tried but payment didn\'t complete')
->descriptionIcon('heroicon-m-exclamation-triangle')
->color($incomplete7d > 50 ? 'danger' : ($incomplete7d > 0 ? 'warning' : 'success')),
Stat::make('Cancelled Subscriptions', $cancelledSG)
->description('Monthly supporters who stopped in last 30 days')
->descriptionIcon($cancelledSG > 0 ? 'heroicon-m-arrow-down' : 'heroicon-m-check-circle')
->color($cancelledSG > 5 ? 'warning' : 'success'),
Stat::make('System Problems', $errorsToday)
->description($errorsToday > 0 ? 'Errors today — check activity log' : 'No problems today')
->descriptionIcon($errorsToday > 0 ? 'heroicon-m-x-circle' : 'heroicon-m-check-circle')
->color($errorsToday > 0 ? 'danger' : 'success')
->url(route('filament.admin.resources.event-logs.index')),
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\CustomerResource;
use App\Models\Donation;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class RecentDonationsWidget extends BaseWidget
{
protected static ?int $sort = 4;
protected int | string | array $columnSpan = 'full';
protected static ?string $heading = 'Latest Donations';
protected int $defaultPaginationPageOption = 5;
public function table(Table $table): Table
{
return $table
->query(
Donation::query()
->with(['customer', 'donationType', 'donationConfirmation', 'appeal'])
->latest('created_at')
)
->columns([
IconColumn::make('is_confirmed')
->label('')
->boolean()
->getStateUsing(fn (Donation $r) => $r->isConfirmed())
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
TextColumn::make('customer.name')
->label('Donor')
->description(fn (Donation $d) => $d->customer?->email)
->searchable(query: function (\Illuminate\Database\Eloquent\Builder $query, string $search) {
$query->whereHas('customer', fn ($q) => $q
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
);
}),
TextColumn::make('amount')
->label('Amount')
->money('gbp', divideBy: 100)
->sortable()
->weight('bold'),
TextColumn::make('donationType.display_name')
->label('Cause')
->badge()
->color('success')
->limit(20),
TextColumn::make('appeal.name')
->label('Fundraiser')
->limit(20)
->placeholder('Direct donation'),
TextColumn::make('created_at')
->label('When')
->since()
->sortable(),
])
->actions([
Action::make('view_donor')
->label('View Donor')
->icon('heroicon-o-user')
->url(fn (Donation $d) => $d->customer_id
? CustomerResource::getUrl('edit', ['record' => $d->customer_id])
: null)
->visible(fn (Donation $d) => (bool) $d->customer_id),
])
->paginated([5, 10])
->defaultSort('created_at', 'desc');
}
}