diff --git a/pledge-now-pay-later/src/app/(auth)/login/page.tsx b/pledge-now-pay-later/src/app/(auth)/login/page.tsx index 5432766..1b267ed 100644 --- a/pledge-now-pay-later/src/app/(auth)/login/page.tsx +++ b/pledge-now-pay-later/src/app/(auth)/login/page.tsx @@ -1,25 +1,27 @@ "use client" -import { useState } from "react" +import { useState, useEffect, Suspense } from "react" import { signIn } from "next-auth/react" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" import Link from "next/link" -export default function LoginPage() { +function LoginForm() { const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [error, setError] = useState("") const [loading, setLoading] = useState(false) const router = useRouter() + const searchParams = useSearchParams() + const isDemo = searchParams.get("demo") === "1" - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const doLogin = async (e?: React.FormEvent, demoEmail?: string, demoPass?: string) => { + if (e) e.preventDefault() setError("") setLoading(true) const result = await signIn("credentials", { - email, - password, + email: demoEmail || email, + password: demoPass || password, redirect: false, }) @@ -31,6 +33,12 @@ export default function LoginPage() { } } + const handleSubmit = (e: React.FormEvent) => doLogin(e) + + // Auto-login as demo if ?demo=1 + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { if (isDemo) doLogin(undefined, "demo@pnpl.app", "demo1234") }, []) + return (
@@ -82,6 +90,20 @@ export default function LoginPage() { +
+
+
or
+
+ + +

Don't have an account?{" "} @@ -92,3 +114,11 @@ export default function LoginPage() {

) } + +export default function LoginPage() { + return ( +
}> + +
+ ) +} diff --git a/pledge-now-pay-later/src/app/api/admin/route.ts b/pledge-now-pay-later/src/app/api/admin/route.ts new file mode 100644 index 0000000..ec54cbd --- /dev/null +++ b/pledge-now-pay-later/src/app/api/admin/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { getUser } from "@/lib/session" + +/** + * GET /api/admin — Super admin overview + * Returns all orgs, users, pledges across the entire platform. + */ +export async function GET() { + const user = await getUser() + if (!user || user.role !== "super_admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 }) + + const [orgs, users, pledgeStats, recentPledges, eventCount] = await Promise.all([ + prisma.organization.findMany({ + select: { + id: true, name: true, slug: true, country: true, createdAt: true, + bankSortCode: true, bankAccountNo: true, + _count: { select: { users: true, events: true, pledges: true } }, + }, + orderBy: { createdAt: "desc" }, + }), + prisma.user.findMany({ + select: { + id: true, email: true, name: true, role: true, createdAt: true, + organization: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + }), + prisma.pledge.groupBy({ + by: ["status"], + _count: true, + _sum: { amountPence: true }, + }), + prisma.pledge.findMany({ + select: { + id: true, reference: true, amountPence: true, status: true, + donorName: true, createdAt: true, dueDate: true, + event: { select: { name: true } }, + organization: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 20, + }), + prisma.event.count(), + ]) + + const totalPledgedPence = pledgeStats.reduce((s, p) => s + (p._sum.amountPence || 0), 0) + const totalCollectedPence = pledgeStats + .filter(p => p.status === "paid") + .reduce((s, p) => s + (p._sum.amountPence || 0), 0) + const totalPledgeCount = pledgeStats.reduce((s, p) => s + p._count, 0) + + return NextResponse.json({ + platform: { + orgs: orgs.length, + users: users.length, + events: eventCount, + pledges: totalPledgeCount, + totalPledgedPence, + totalCollectedPence, + collectionRate: totalPledgedPence > 0 ? Math.round((totalCollectedPence / totalPledgedPence) * 100) : 0, + }, + orgs: orgs.map(o => ({ + id: o.id, + name: o.name, + slug: o.slug, + country: o.country, + hasBank: !!(o.bankSortCode && o.bankAccountNo), + users: o._count.users, + events: o._count.events, + pledges: o._count.pledges, + createdAt: o.createdAt, + })), + users: users.map(u => ({ + id: u.id, + email: u.email, + name: u.name, + role: u.role, + orgName: u.organization.name, + createdAt: u.createdAt, + })), + byStatus: Object.fromEntries(pledgeStats.map(p => [p.status, { count: p._count, amount: p._sum.amountPence || 0 }])), + recentPledges: recentPledges.map(p => ({ + id: p.id, + reference: p.reference, + amountPence: p.amountPence, + status: p.status, + donorName: p.donorName, + eventName: p.event.name, + orgName: p.organization.name, + dueDate: p.dueDate, + createdAt: p.createdAt, + })), + }) +} diff --git a/pledge-now-pay-later/src/app/dashboard/admin/page.tsx b/pledge-now-pay-later/src/app/dashboard/admin/page.tsx new file mode 100644 index 0000000..72bc45e --- /dev/null +++ b/pledge-now-pay-later/src/app/dashboard/admin/page.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useState, useEffect } from "react" +import { useSession } from "next-auth/react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { + Shield, Building2, Users, Banknote, Calendar, TrendingUp, Loader2, AlertTriangle +} from "lucide-react" + +interface AdminData { + platform: { orgs: number; users: number; events: number; pledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number } + orgs: Array<{ id: string; name: string; slug: string; hasBank: boolean; users: number; events: number; pledges: number; createdAt: string }> + users: Array<{ id: string; email: string; name: string | null; role: string; orgName: string; createdAt: string }> + byStatus: Record + recentPledges: Array<{ id: string; reference: string; amountPence: number; status: string; donorName: string | null; eventName: string; orgName: string; dueDate: string | null; createdAt: string }> +} + +const fmt = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` + +export default function AdminPage() { + const { data: session } = useSession() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user = session?.user as any + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [tab, setTab] = useState("orgs") + + useEffect(() => { + fetch("/api/admin") + .then(r => { if (!r.ok) throw new Error("Forbidden"); return r.json() }) + .then(d => setData(d)) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)) + }, []) + + if (user?.role !== "super_admin") { + return ( +
+ +

Access Denied

+

Super admin access required.

+
+ ) + } + + if (loading) return
+ if (error || !data) return

{error}

+ + const p = data.platform + + return ( +
+
+ +
+

Super Admin

+

Platform-wide view · {user?.email}

+
+
+ + {/* Platform stats */} +
+ {[ + { label: "Orgs", value: p.orgs, icon: Building2, color: "text-trust-blue" }, + { label: "Users", value: p.users, icon: Users, color: "text-warm-amber" }, + { label: "Events", value: p.events, icon: Calendar, color: "text-purple-500" }, + { label: "Pledges", value: p.pledges, icon: TrendingUp, color: "text-success-green" }, + { label: "Pledged", value: fmt(p.totalPledgedPence), icon: Banknote, color: "text-trust-blue" }, + { label: "Collected", value: fmt(p.totalCollectedPence), icon: Banknote, color: "text-success-green" }, + { label: "Rate", value: `${p.collectionRate}%`, icon: TrendingUp, color: p.collectionRate > 50 ? "text-success-green" : "text-warm-amber" }, + ].map(s => ( + + +
+ + {s.label} +
+

{s.value}

+
+
+ ))} +
+ + {/* Pipeline */} + + + Pipeline + + +
+ {Object.entries(data.byStatus).map(([status, { count, amount }]) => ( +
+ {status} +

{count}

+

{fmt(amount)}

+
+ ))} +
+
+
+ + + + Organisations ({data.orgs.length}) + Users ({data.users.length}) + Recent Pledges + + + + + + + + + Name + Bank + Users + Events + Pledges + Created + + + + {data.orgs.map(o => ( + + +

{o.name}

+

{o.slug}

+
+ + {o.hasBank ? ✓ Set : Missing} + + {o.users} + {o.events} + {o.pledges} + {new Date(o.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} +
+ ))} +
+
+
+
+
+ + + + + + + + Email + Name + Role + Organisation + Joined + + + + {data.users.map(u => ( + + {u.email} + {u.name || "—"} + + + {u.role === "super_admin" ? "🛡️ Super" : u.role === "org_admin" ? "Admin" : u.role} + + + {u.orgName} + {new Date(u.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} + + ))} + +
+
+
+
+ + + + + + + + Reference + Donor + Amount + Status + Event + Org + Date + + + + {data.recentPledges.map(p => ( + + {p.reference} + {p.donorName || "Anon"} + {fmt(p.amountPence)} + + {p.status} + + {p.eventName} + {p.orgName} + {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/pledge-now-pay-later/src/app/dashboard/layout.tsx b/pledge-now-pay-later/src/app/dashboard/layout.tsx index b8dc596..7aebb10 100644 --- a/pledge-now-pay-later/src/app/dashboard/layout.tsx +++ b/pledge-now-pay-later/src/app/dashboard/layout.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { useSession, signOut } from "next-auth/react" -import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut } from "lucide-react" +import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut, Shield } from "lucide-react" import { cn } from "@/lib/utils" const navItems = [ @@ -15,6 +15,8 @@ const navItems = [ { 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() @@ -76,6 +78,23 @@ export default function DashboardLayout({ children }: { children: React.ReactNod ) })} + {user?.role === "super_admin" && ( + <> +
+ + + {adminNav.label} + + + )}
diff --git a/pledge-now-pay-later/src/app/page.tsx b/pledge-now-pay-later/src/app/page.tsx index 1373104..bcee079 100644 --- a/pledge-now-pay-later/src/app/page.tsx +++ b/pledge-now-pay-later/src/app/page.tsx @@ -41,9 +41,9 @@ export default function HomePage() { Start Collecting Pledges → - - See How It Works - + + 🎮 Try the Demo +

Free forever · No card needed · 2 minute setup