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:
113
PERSONA_ANALYSIS.md
Normal file
113
PERSONA_ANALYSIS.md
Normal 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."
|
||||||
@@ -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'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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
50
temp_files/DonationChartWidget.php
Normal file
50
temp_files/DonationChartWidget.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
85
temp_files/DonationStatsWidget.php
Normal file
85
temp_files/DonationStatsWidget.php
Normal 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
127
temp_files/EditCustomer.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
21
temp_files/ListAppeals.php
Normal file
21
temp_files/ListAppeals.php
Normal 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
21
temp_files/ListApprovalQueues.php
Normal file
21
temp_files/ListApprovalQueues.php
Normal 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
26
temp_files/ListCustomers.php
Normal file
26
temp_files/ListCustomers.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
temp_files/ListDonations.php
Normal file
26
temp_files/ListDonations.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
temp_files/OperationalHealthWidget.php
Normal file
62
temp_files/OperationalHealthWidget.php
Normal 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')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
temp_files/RecentDonationsWidget.php
Normal file
86
temp_files/RecentDonationsWidget.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user