P0 Critical (7): - STOP/UNSUBSCRIBE keyword → CANCEL (PECR compliance) - Rate limiting on pledge creation (10/IP/5min) - Terms of Service + Privacy Policy pages - WhatsApp onboarding gate (persistent dashboard banner) - Demo account seeding (demo@pnpl.app) - Footer legal links - Basic accessibility (aria labels on donor flow) P1 Within 2 Weeks (8): - Pledge editing by staff (PATCH amount, name, email, phone, rail) - Donor self-cancel page (/p/cancel) + API - Donor 'My Pledges' lookup page (/p/my-pledges) - Bulk QR code download (print-ready HTML) - Public event progress bar (/e/[slug]/progress) - Email-only donor handling (honest status + WhatsApp fallback) - Email verification (format + disposable domain blocking) - Organisations page rewrite (multi-campaign, not multi-org) P2 Within First Month (10): - Event cloning with QR sources - Account deletion (GDPR Article 17) - Daily digest cron via WhatsApp - AI-6 Smart reminder timing (due date anchoring, cultural sensitivity) - H1 Duplicate donor detection (email, phone, Jaro-Winkler name) - H5 Bank CSV format presets (10 UK banks) - H16 Partial payment matching (underpay, overpay, instalment) - H10 Activity logging (audit trail for staff actions) - AI nudge endpoint + AI column mapping + AI event setup wizard - AI anomaly detection wired into daily digest AI Features (11): smart reconciliation, social proof, auto column mapper, daily digest, impact storyteller, smart timing, nudge composer, event wizard, NLU concierge, anomaly detection, bank presets 22 new files, 15 modified files, 0 TypeScript errors, clean build.
186 lines
7.9 KiB
TypeScript
186 lines
7.9 KiB
TypeScript
"use client"
|
|
|
|
import Link from "next/link"
|
|
import { usePathname } from "next/navigation"
|
|
import { useSession, signOut } from "next-auth/react"
|
|
import { useState, useEffect } from "react"
|
|
import { LayoutDashboard, Megaphone, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut, Shield, MessageCircle, AlertTriangle } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const navItems = [
|
|
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
|
|
{ href: "/dashboard/events", label: "Campaigns", icon: Megaphone },
|
|
{ href: "/dashboard/pledges", label: "Pledges", icon: FileBarChart },
|
|
{ href: "/dashboard/reconcile", label: "Reconcile", icon: Upload },
|
|
{ href: "/dashboard/exports", label: "Exports", icon: Download },
|
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
|
]
|
|
|
|
const adminNav = { href: "/dashboard/admin", label: "Super Admin", icon: Shield }
|
|
|
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
const pathname = usePathname()
|
|
const { data: session } = useSession()
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const user = session?.user as any
|
|
|
|
return (
|
|
<div className="min-h-screen bg-paper">
|
|
{/* Top bar — sharp, no blur */}
|
|
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white">
|
|
<div className="flex h-14 items-center gap-4 px-4 md:px-6">
|
|
<Link href="/dashboard" className="flex items-center gap-2.5">
|
|
<div className="h-7 w-7 bg-midnight flex items-center justify-center">
|
|
<span className="text-white text-xs font-black">P</span>
|
|
</div>
|
|
<div className="hidden sm:block">
|
|
<span className="font-black text-sm text-midnight">{user?.orgName || "Pledge Now, Pay Later"}</span>
|
|
</div>
|
|
</Link>
|
|
<div className="flex-1" />
|
|
<Link href="/dashboard/events" className="hidden md:block">
|
|
<button className="inline-flex items-center gap-1.5 bg-midnight px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-800 transition-colors">
|
|
<Plus className="h-3 w-3" /> New Campaign
|
|
</button>
|
|
</Link>
|
|
<Link href="/" className="text-xs text-gray-400 hover:text-midnight transition-colors flex items-center gap-1">
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
{session && (
|
|
<button
|
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
className="text-xs text-gray-400 hover:text-midnight transition-colors flex items-center gap-1"
|
|
>
|
|
<LogOut className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex">
|
|
{/* Desktop sidebar — clean, no decorative elements */}
|
|
<aside className="hidden md:flex w-52 flex-col border-r border-gray-200 bg-white min-h-[calc(100vh-3.5rem)] py-3 px-2">
|
|
<nav className="space-y-0.5">
|
|
{navItems.map((item) => {
|
|
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cn(
|
|
"flex items-center gap-2.5 px-3 py-2 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5"
|
|
: "text-gray-500 hover:bg-gray-50 hover:text-midnight"
|
|
)}
|
|
>
|
|
<item.icon className="h-4 w-4" />
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
})}
|
|
{user?.role === "super_admin" && (
|
|
<>
|
|
<div className="my-2 border-t border-gray-100" />
|
|
<Link
|
|
href={adminNav.href}
|
|
className={cn(
|
|
"flex items-center gap-2.5 px-3 py-2 text-sm font-medium transition-colors",
|
|
pathname === adminNav.href
|
|
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5"
|
|
: "text-gray-500 hover:bg-gray-50 hover:text-midnight"
|
|
)}
|
|
>
|
|
<adminNav.icon className="h-4 w-4" />
|
|
{adminNav.label}
|
|
</Link>
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
<div className="mt-auto px-2 pt-4">
|
|
<div className="border border-gray-200 p-3 space-y-1.5">
|
|
<p className="text-xs font-bold text-midnight">Need help?</p>
|
|
<p className="text-[10px] text-gray-500 leading-relaxed">
|
|
Get a fractional Head of Technology to optimise your charity's digital stack.
|
|
</p>
|
|
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-promise-blue hover:underline">
|
|
Learn more →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Mobile bottom nav */}
|
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white flex justify-around py-1.5 px-1">
|
|
{navItems.slice(0, 5).map((item) => {
|
|
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cn(
|
|
"flex flex-col items-center gap-0.5 py-1 px-2 transition-colors",
|
|
isActive ? "text-promise-blue" : "text-gray-400"
|
|
)}
|
|
>
|
|
<item.icon className="h-5 w-5" />
|
|
<span className="text-[9px] font-medium">{item.label}</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* Main content */}
|
|
<main className="flex-1 p-4 md:p-6 pb-20 md:pb-6 max-w-6xl">
|
|
<WhatsAppBanner />
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Persistent WhatsApp connection banner — shows until connected */
|
|
function WhatsAppBanner() {
|
|
const [status, setStatus] = useState<string | null>(null)
|
|
const [dismissed, setDismissed] = useState(false)
|
|
const pathname = usePathname()
|
|
|
|
useEffect(() => {
|
|
// Don't show on settings page (they're already there)
|
|
if (pathname === "/dashboard/settings") { setStatus("skip"); return }
|
|
fetch("/api/whatsapp/send")
|
|
.then(r => r.json())
|
|
.then(data => setStatus(data.connected ? "CONNECTED" : "OFFLINE"))
|
|
.catch(() => setStatus("OFFLINE"))
|
|
}, [pathname])
|
|
|
|
if (status === "CONNECTED" || status === "skip" || status === null || dismissed) return null
|
|
|
|
return (
|
|
<div className="mb-4 rounded-lg border-2 border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
|
|
<div className="w-9 h-9 rounded-lg bg-amber-100 flex items-center justify-center shrink-0 mt-0.5">
|
|
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-bold text-gray-900">WhatsApp not connected — reminders won't send</p>
|
|
<p className="text-xs text-gray-600 mt-0.5">
|
|
Connect your WhatsApp to auto-send pledge receipts and payment reminders to donors. Takes 60 seconds.
|
|
</p>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<Link
|
|
href="/dashboard/settings"
|
|
className="inline-flex items-center gap-1.5 bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors rounded"
|
|
>
|
|
<MessageCircle className="h-3.5 w-3.5" /> Connect WhatsApp
|
|
</Link>
|
|
<button onClick={() => setDismissed(true)} className="text-xs text-gray-400 hover:text-gray-600">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|