## New: Community Leader role Who: Imam Yusuf, Sister Mariam, Uncle Tariq — the person who rallies their mosque, WhatsApp group, neighbourhood to pledge. Not an admin. Not a volunteer. A logged-in coordinator who needs more than a live feed but less than full admin access. /dashboard/community — their scoped dashboard: - 'How are WE doing?' — their stats vs the whole appeal (dark hero section) - Contribution percentage bar - Their links with full share buttons (Copy/WhatsApp/Email/QR) - Create new links (auto-tagged with their name) - Leaderboard: 'How communities compare' with 'You' badge - Read-only pledge list (no status changes, no bank details) Navigation changes for community_leader role: - Sees: My Community → Share Links → Reports (3 items) - Does NOT see: Home, Money, Settings, New Appeal button - Does NOT see: Bank details, WhatsApp config, reconciliation ## New: Team management API + UI GET/POST/PATCH/DELETE /api/team — CRUD for team members - Only org_admin/super_admin can invite - Temp password generated on invite (shown once) - Copy credentials or send via WhatsApp button - Role selector with descriptions (Admin, Community Leader, Staff, Volunteer) - Role change via dropdown, remove with trash icon - Can't change own role or remove self ## Settings page redesign Reordered by Aaisha's thinking: 1. WhatsApp (unchanged — most important) 2. Team (NEW — 'who has access? invite community leaders') 3. Bank account 4. Charity details 5. Direct Debit (collapsed in <details>) Team section shows: - All members with role icons (Crown/Users/Eye) - Inline role change dropdown - Remove button - Invite form with role cards and descriptions - Credentials shown once with copy + WhatsApp share buttons ## Admin page redesign Brand-consistent: no more shadcn Card/Badge/Table - Dark hero section with 7 platform stats - Pipeline status breakdown (gap-px grid) - Pill tab switcher (not shadcn Tabs) - Grid tables matching the rest of the dashboard - Role badges color-coded (blue super, green admin, amber leader) 6 files changed, 4 new routes/pages
351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback } from "react"
|
|
import { useSession } from "next-auth/react"
|
|
import { formatPence } from "@/lib/utils"
|
|
import {
|
|
Loader2, Copy, Check, MessageCircle, Mail, Trophy,
|
|
Plus, Link2, Download, QrCode as QrCodeIcon
|
|
} from "lucide-react"
|
|
import { QRCodeCanvas } from "@/components/qr-code"
|
|
|
|
/**
|
|
* /dashboard/community — Community Leader's dashboard
|
|
*
|
|
* Who is the Community Leader?
|
|
* Imam Yusuf. Sister Mariam. Uncle Tariq. The person who rallies
|
|
* their mosque, WhatsApp group, neighbourhood to pledge.
|
|
*
|
|
* Their mental model:
|
|
* 1. "How are WE doing?" → their community's stats vs the whole appeal
|
|
* 2. "I need to share the link" → share buttons, front and center
|
|
* 3. "Who from my group has pledged?" → simple donor list
|
|
* 4. "How do we compare?" → leaderboard across all communities
|
|
*
|
|
* What they DON'T see:
|
|
* - Bank details, WhatsApp settings, charity config
|
|
* - Other communities' donor details
|
|
* - Ability to confirm payments or change statuses
|
|
* - Full reconciliation
|
|
*
|
|
* This is a SCOPED, READ-MOSTLY view.
|
|
* They can create new links and share. That's it.
|
|
*/
|
|
|
|
interface EventSummary {
|
|
id: string; name: string; totalPledged: number; totalCollected: number; pledgeCount: number
|
|
}
|
|
|
|
interface SourceInfo {
|
|
id: string; label: string; code: string; volunteerName: string | null
|
|
scanCount: number; pledgeCount: number; totalPledged: number; totalCollected?: number
|
|
}
|
|
|
|
interface PledgeInfo {
|
|
id: string; donorName: string | null; amountPence: number; status: string; createdAt: string
|
|
}
|
|
|
|
const STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
|
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
|
|
initiated: { label: "Said paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
|
paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
|
|
overdue: { label: "Overdue", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
|
|
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
|
|
}
|
|
|
|
export default function CommunityPage() {
|
|
const { data: session } = useSession()
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const user = session?.user as any
|
|
|
|
const [events, setEvents] = useState<EventSummary[]>([])
|
|
const [allSources, setAllSources] = useState<SourceInfo[]>([])
|
|
const [mySources, setMySources] = useState<SourceInfo[]>([])
|
|
const [myPledges, setMyPledges] = useState<PledgeInfo[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
|
const [showQr, setShowQr] = useState<string | null>(null)
|
|
|
|
// Create link
|
|
const [newLinkName, setNewLinkName] = useState("")
|
|
const [creating, setCreating] = useState(false)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
|
|
const userName = user?.name || user?.email?.split("@")[0] || ""
|
|
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
const [evRes, dashRes] = await Promise.all([
|
|
fetch("/api/events").then(r => r.json()),
|
|
fetch("/api/dashboard").then(r => r.json()),
|
|
])
|
|
|
|
if (Array.isArray(evRes)) {
|
|
setEvents(evRes)
|
|
// Load sources for first event
|
|
if (evRes.length > 0) {
|
|
const srcRes = await fetch(`/api/events/${evRes[0].id}/qr`).then(r => r.json())
|
|
if (Array.isArray(srcRes)) {
|
|
setAllSources(srcRes)
|
|
// Filter to "my" sources — those with this user's name or created by this user
|
|
const mine = srcRes.filter((s: SourceInfo) =>
|
|
s.volunteerName?.toLowerCase().includes(userName.toLowerCase()) ||
|
|
s.label.toLowerCase().includes(userName.toLowerCase())
|
|
)
|
|
setMySources(mine.length > 0 ? mine : srcRes.slice(0, 3)) // fallback: show first 3
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get pledges (scoped view — show recent)
|
|
if (dashRes.pledges) {
|
|
setMyPledges(dashRes.pledges.slice(0, 20))
|
|
}
|
|
} catch { /* */ }
|
|
setLoading(false)
|
|
}, [userName])
|
|
|
|
useEffect(() => { loadData() }, [loadData])
|
|
|
|
// Actions
|
|
const copyLink = async (code: string) => {
|
|
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
|
|
setCopiedCode(code)
|
|
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 createLink = async () => {
|
|
if (!newLinkName.trim() || events.length === 0) return
|
|
setCreating(true)
|
|
try {
|
|
const res = await fetch(`/api/events/${events[0].id}/qr`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ label: newLinkName.trim(), volunteerName: userName }),
|
|
})
|
|
if (res.ok) {
|
|
const src = await res.json()
|
|
const newSrc = { ...src, scanCount: 0, pledgeCount: 0, totalPledged: 0, totalCollected: 0 }
|
|
setMySources(prev => [newSrc, ...prev])
|
|
setAllSources(prev => [newSrc, ...prev])
|
|
setNewLinkName("")
|
|
setShowCreate(false)
|
|
}
|
|
} catch { /* */ }
|
|
setCreating(false)
|
|
}
|
|
|
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
|
|
|
const activeEvent = events[0]
|
|
const myTotal = mySources.reduce((s, l) => s + l.totalPledged, 0)
|
|
const myPledgeCount = mySources.reduce((s, l) => s + l.pledgeCount, 0)
|
|
const appealTotal = activeEvent?.totalPledged || 0
|
|
const myPct = appealTotal > 0 ? Math.round((myTotal / appealTotal) * 100) : 0
|
|
|
|
// Leaderboard (all sources, sorted by amount)
|
|
const leaderboard = [...allSources].sort((a, b) => b.totalPledged - a.totalPledged)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
|
|
{/* ── Header: "How are WE doing?" ── */}
|
|
<div>
|
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
{activeEvent?.name || "Your community"}
|
|
</p>
|
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">
|
|
Welcome back, {userName.split(" ")[0] || "Leader"}
|
|
</h1>
|
|
</div>
|
|
|
|
{/* ── My community's stats vs the whole appeal ── */}
|
|
<div className="bg-[#111827] p-6">
|
|
<div className="grid grid-cols-2 gap-px bg-gray-700">
|
|
<div className="bg-[#111827] p-4">
|
|
<p className="text-[10px] text-gray-500">Your community</p>
|
|
<p className="text-3xl font-black text-white tracking-tight">{formatPence(myTotal)}</p>
|
|
<p className="text-xs text-gray-400">{myPledgeCount} pledges from {mySources.length} links</p>
|
|
</div>
|
|
<div className="bg-[#111827] p-4">
|
|
<p className="text-[10px] text-gray-500">Whole appeal</p>
|
|
<p className="text-3xl font-black text-gray-400 tracking-tight">{formatPence(appealTotal)}</p>
|
|
<p className="text-xs text-gray-400">{activeEvent?.pledgeCount || 0} total pledges</p>
|
|
</div>
|
|
</div>
|
|
{appealTotal > 0 && (
|
|
<div className="mt-4 pt-3 border-t border-gray-800">
|
|
<div className="flex justify-between text-xs text-gray-500 mb-2">
|
|
<span>Your contribution</span>
|
|
<span className="font-bold text-white">{myPct}%</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-800 overflow-hidden">
|
|
<div className="h-full bg-[#4ADE80] transition-all duration-700" style={{ width: `${myPct}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Your links + share ── */}
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-bold text-[#111827]">Your links ({mySources.length})</h2>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="bg-[#111827] px-3 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>
|
|
|
|
{showCreate && (
|
|
<div className="bg-white border-2 border-[#1E40AF] p-4 space-y-3">
|
|
<p className="text-sm font-bold text-[#111827]">Create a link for your community</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
value={newLinkName}
|
|
onChange={e => setNewLinkName(e.target.value)}
|
|
placeholder='e.g. "Friday Halaqa", "Sisters WhatsApp", "Youth Group"'
|
|
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] outline-none"
|
|
/>
|
|
<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">
|
|
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}
|
|
</button>
|
|
</div>
|
|
<button onClick={() => { setShowCreate(false); setNewLinkName("") }} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
|
</div>
|
|
)}
|
|
|
|
{mySources.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 links yet</p>
|
|
<p className="text-xs text-gray-500 mt-1">Create a link to start collecting pledges for your community</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{mySources.map(src => {
|
|
const url = `${baseUrl}/p/${src.code}`
|
|
const isCopied = copiedCode === src.code
|
|
const isQrOpen = showQr === src.code
|
|
|
|
return (
|
|
<div key={src.id} className="bg-white border border-gray-200">
|
|
<div className="p-4">
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
|
<p className="text-sm font-bold text-[#111827]">{src.label}</p>
|
|
<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.pledgeCount}</p>
|
|
<p className="text-[8px] text-gray-500">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">raised</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-[#F9FAFB] px-3 py-2 mb-3">
|
|
<p className="text-xs font-mono text-gray-500 truncate">{url}</p>
|
|
</div>
|
|
|
|
<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={() => window.open(`mailto:?subject=${encodeURIComponent(`Pledge: ${src.label}`)}&body=${encodeURIComponent(`Pledge here:\n${url}`)}`)} className="border border-gray-200 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={() => setShowQr(isQrOpen ? null : src.code)} className="border border-gray-200 py-2.5 text-xs font-bold hover:bg-gray-50 transition-colors flex items-center justify-center gap-1.5">
|
|
<QrCodeIcon className="h-3.5 w-3.5" /> QR
|
|
</button>
|
|
</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>
|
|
<a href={`/api/events/${events[0]?.id}/qr/${src.id}/download?code=${src.code}`} download className="text-[10px] font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
|
|
<Download className="h-3 w-3" /> Download QR image
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Leaderboard — how do we compare? ── */}
|
|
{leaderboard.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">
|
|
<h3 className="text-sm font-bold text-[#111827] flex items-center gap-1.5">
|
|
<Trophy className="h-4 w-4 text-[#F59E0B]" /> How communities compare
|
|
</h3>
|
|
</div>
|
|
<div className="divide-y divide-gray-50">
|
|
{leaderboard.filter(s => s.pledgeCount > 0).slice(0, 10).map((src, i) => {
|
|
const isMine = mySources.some(m => m.id === src.id)
|
|
const medals = ["bg-[#F59E0B]", "bg-gray-400", "bg-[#CD7F32]"]
|
|
return (
|
|
<div key={src.id} className={`px-5 py-3 flex items-center gap-3 ${isMine ? "bg-[#1E40AF]/5" : ""}`}>
|
|
<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">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-medium text-[#111827] truncate">{src.volunteerName || src.label}</p>
|
|
{isMine && <span className="text-[9px] font-bold text-[#1E40AF] bg-[#1E40AF]/10 px-1 py-0.5">You</span>}
|
|
</div>
|
|
<p className="text-[10px] text-gray-500">{src.pledgeCount} pledges</p>
|
|
</div>
|
|
<p className="text-sm font-black text-[#111827]">{formatPence(src.totalPledged)}</p>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Recent pledges (read-only, no actions) ── */}
|
|
{myPledges.length > 0 && (
|
|
<div className="bg-white border border-gray-200">
|
|
<div className="border-b border-gray-100 px-5 py-3">
|
|
<h3 className="text-sm font-bold text-[#111827]">Recent pledges</h3>
|
|
</div>
|
|
<div className="divide-y divide-gray-50">
|
|
{myPledges.slice(0, 10).map(p => {
|
|
const sl = STATUS[p.status] || STATUS.new
|
|
const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
|
|
const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : `${days}d ago`
|
|
return (
|
|
<div key={p.id} className="px-5 py-3 flex items-center gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
|
<p className="text-[10px] text-gray-500">{when}</p>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
|
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|