Settings + Admin redesign + Community Leader role
## 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
This commit is contained in:
350
pledge-now-pay-later/src/app/dashboard/community/page.tsx
Normal file
350
pledge-now-pay-later/src/app/dashboard/community/page.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user