Files
calvana/pledge-now-pay-later/src/app/dashboard/community/page.tsx
Omair Saleh b771858280 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
2026-03-04 21:48:10 +08:00

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>
)
}