diff --git a/__tests__/admin/AdminSpacesPage.test.jsx b/__tests__/admin/AdminSpacesPage.test.jsx new file mode 100644 index 00000000..76384977 --- /dev/null +++ b/__tests__/admin/AdminSpacesPage.test.jsx @@ -0,0 +1,98 @@ +/** + * Admin Spaces Oversight page — loading / data / filter tests (#270). + * ------------------------------------------------------------------- + * The page renders a table of community spaces with title, host, type, + * scheduled time, status, participant count, and flags. Live rooms are + * sorted to the top with a pulsing status dot. Filters by host and + * status are available. Em-dash is rendered for unknown participant + * counts instead of zeros. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; + +vi.mock("@/lib/config/font.config", () => ({ + poppins_400: { className: "" }, + poppins_500: { className: "" }, + poppins_600: { className: "" }, +})); + +vi.mock("@/components/ui/empty-state", () => ({ + EmptyState: ({ title, description }) => ( +
+

{title}

+

{description}

+
+ ), +})); + +let AdminSpacesPage; +beforeEach(async () => { + if (!AdminSpacesPage) { + const mod = await import("@/app/[locale]/admin/spaces/page"); + AdminSpacesPage = mod.default; + } +}); + +describe("AdminSpacesPage — states", () => { + it("renders skeleton placeholders while loading", () => { + const { container } = render(); + expect(screen.getByText("Spaces Oversight")).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="skeleton"]') + ).not.toBeNull(); + }); + + it("renders summary stat cards after data loads", async () => { + render(); + + await screen.findByText("Live Now"); + expect(screen.getByText("Scheduled")).toBeInTheDocument(); + expect(screen.getByText("Ended")).toBeInTheDocument(); + }); + + it("renders the spaces table with data after loading", async () => { + render(); + + await screen.findByText("Quran Study Circle"); + expect(screen.getByText("Youth Halaqah")).toBeInTheDocument(); + expect(screen.getByText("Islamic History Discussion")).toBeInTheDocument(); + }); + + it("renders pulsing status dot for live rooms", async () => { + render(); + await screen.findByText("Quran Study Circle"); + + expect(screen.getAllByLabelText("Live").length).toBeGreaterThanOrEqual(1); + }); + + it("renders em-dash for unknown participant counts", async () => { + render(); + await screen.findByText("Quran Study Circle"); + + const dashes = screen.getAllByText("—"); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it("renders participant count for live rooms", async () => { + render(); + await screen.findByText("Quran Study Circle"); + + expect(screen.getByText("24")).toBeInTheDocument(); + }); + + it("renders Jitsi badge for room type", async () => { + render(); + await screen.findByText("Quran Study Circle"); + + const jitsiBadges = screen.getAllByText("Jitsi"); + expect(jitsiBadges.length).toBeGreaterThanOrEqual(1); + }); + + it("renders filter controls", async () => { + render(); + await screen.findByText("Quran Study Circle"); + + expect(screen.getByLabelText("Status")).toBeInTheDocument(); + expect(screen.getByLabelText("Host")).toBeInTheDocument(); + }); +}); diff --git a/app/[locale]/admin/spaces/page.jsx b/app/[locale]/admin/spaces/page.jsx new file mode 100644 index 00000000..8eba992d --- /dev/null +++ b/app/[locale]/admin/spaces/page.jsx @@ -0,0 +1,576 @@ +"use client"; + +/** + * Admin Spaces Oversight (#270) + * + * Comprehensive overview of all live and scheduled community spaces. + * - Table with title, host, type, scheduled time, status, participant count, flags + * - Live rooms sorted to top with pulsing status dot + * - Filters by host and status + * - Em-dash for unknown participant counts + * - Jitsi room type display + */ + +import { useState, useMemo, useCallback, useEffect } from "react"; +import { PageShell } from "@/components/ui/page-shell"; +import { PageHeader } from "@/components/ui/page-header"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +import { + Video, + Users, + Calendar, + Radio, + Clock, + AlertTriangle, + RefreshCw, + Search, + Flag, + AudioWaveform, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config"; +import { format, formatDistanceToNow } from "date-fns"; + +// ─── Mock data ────────────────────────────────────────────────────────────── + +const MOCK_SPACES = [ + { + id: "sp_001", + title: "Quran Study Circle", + host: { name: "Sheikh Ahmad", avatar: null }, + type: "jitsi", + scheduledTime: new Date().toISOString(), // live now + status: "live", + participantCount: 24, + flags: [], + }, + { + id: "sp_002", + title: "Youth Halaqah", + host: { name: "Ustadh Ibrahim", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() + 3600000).toISOString(), // 1 hour from now + status: "scheduled", + participantCount: null, + flags: [], + }, + { + id: "sp_003", + title: "Islamic History Discussion", + host: { name: "Dr. Fatima", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago + status: "ended", + participantCount: null, + flags: [], + }, + { + id: "sp_004", + title: "Arabic Language Workshop", + host: { name: "Sheikh Ahmad", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() + 86400000).toISOString(), // tomorrow + status: "scheduled", + participantCount: null, + flags: ["flagged"], + }, + { + id: "sp_005", + title: "Tafsir Session", + host: { name: "Dr. Yusuf", avatar: null }, + type: "jitsi", + scheduledTime: new Date().toISOString(), // live now + status: "live", + participantCount: 42, + flags: [], + }, + { + id: "sp_006", + title: "Seerah Study Group", + host: { name: "Sister Maryam", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() + 172800000).toISOString(), // 2 days from now + status: "scheduled", + participantCount: null, + flags: [], + }, + { + id: "sp_007", + title: "Fiqh Q&A", + host: { name: "Sheikh Omar", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + status: "ended", + participantCount: null, + flags: [], + }, + { + id: "sp_008", + title: "Hadith Sciences", + host: { name: "Dr. Fatima", avatar: null }, + type: "jitsi", + scheduledTime: new Date().toISOString(), // live now + status: "live", + participantCount: 15, + flags: [], + }, + { + id: "sp_009", + title: "Quran Recitation Circle", + host: { name: "Ustadh Ibrahim", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() + 43200000).toISOString(), // 12 hours from now + status: "scheduled", + participantCount: null, + flags: [], + }, + { + id: "sp_010", + title: "Community Town Hall", + host: { name: "Admin Team", avatar: null }, + type: "jitsi", + scheduledTime: new Date(Date.now() - 1800000).toISOString(), // 30 min ago + status: "ended", + participantCount: null, + flags: ["flagged"], + }, +]; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const STATUS_CONFIG = { + live: { + label: "Live", + color: "text-green-600", + bg: "bg-green-100 dark:bg-green-900/30", + badge: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + }, + scheduled: { + label: "Scheduled", + color: "text-blue-600", + bg: "bg-blue-100 dark:bg-blue-900/30", + badge: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", + }, + ended: { + label: "Ended", + color: "text-muted-foreground", + bg: "bg-muted", + badge: "bg-muted text-muted-foreground", + }, +}; + +function StatusDot({ status }) { + if (status !== "live") return null; + return ( + + + + + ); +} + +function formatScheduledTime(isoString) { + const date = new Date(isoString); + return format(date, "MMM d, yyyy · h:mm a"); +} + +function formatRelativeTime(isoString) { + return formatDistanceToNow(new Date(isoString), { addSuffix: true }); +} + +function sortSpacesByStatus(spaces) { + const order = { live: 0, scheduled: 1, ended: 2 }; + return [...spaces].sort((a, b) => order[a.status] - order[b.status]); +} + +// ─── Page Component ───────────────────────────────────────────────────────── + +export default function AdminSpacesPage() { + const [spaces, setSpaces] = useState([]); + const [loading, setLoading] = useState(true); + const [hostFilter, setHostFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + + const fetchSpaces = useCallback(async () => { + setLoading(true); + await new Promise((r) => setTimeout(r, 400)); + setSpaces(MOCK_SPACES); + setLoading(false); + }, []); + + useEffect(() => { + fetchSpaces(); + }, [fetchSpaces]); + + // Unique hosts for filter + const hosts = useMemo(() => { + const seen = new Set(); + return spaces + .map((s) => s.host.name) + .filter((name) => { + if (seen.has(name)) return false; + seen.add(name); + return true; + }) + .sort(); + }, [spaces]); + + // Filtered + sorted spaces + const filteredSpaces = useMemo(() => { + let result = [...spaces]; + + if (hostFilter !== "all") { + result = result.filter((s) => s.host.name === hostFilter); + } + if (statusFilter !== "all") { + result = result.filter((s) => s.status === statusFilter); + } + + return sortSpacesByStatus(result); + }, [spaces, hostFilter, statusFilter]); + + // Summary counts + const counts = useMemo(() => { + const c = { live: 0, scheduled: 0, ended: 0 }; + spaces.forEach((s) => { + if (c[s.status] !== undefined) c[s.status]++; + }); + return c; + }, [spaces]); + + return ( + + + + Refresh + + } + /> + + {/* Summary Stats */} +
+ + +
+
+

+ Live Now +

+

+ {loading ? "—" : counts.live} +

+
+
+ +
+
+
+
+ + + +
+
+

+ Scheduled +

+

+ {loading ? "—" : counts.scheduled} +

+
+
+ +
+
+
+
+ + + +
+
+

+ Ended +

+

+ {loading ? "—" : counts.ended} +

+
+
+ +
+
+
+
+
+ + {/* Filters */} + + +
+
+ + + Filters: + +
+ +
+ + +
+ +
+ + +
+ + {(hostFilter !== "all" || statusFilter !== "all") && ( + + )} +
+
+
+ + {/* Spaces Table */} + + + + + + {filteredSpaces.length} space + {filteredSpaces.length !== 1 && "s"} · Live rooms shown first + + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : filteredSpaces.length === 0 ? ( + + ) : ( +
+ + + + Title + Host + Type + Scheduled Time + Status + + Participants + + Flags + + + + {filteredSpaces.map((space) => { + const statusConfig = STATUS_CONFIG[space.status]; + return ( + + {/* Title */} + +
+ +
+

+ {space.title} +

+ {space.status === "scheduled" && ( +

+ {formatRelativeTime(space.scheduledTime)} +

+ )} +
+
+
+ + {/* Host */} + + {space.host.name} + + + {/* Type */} + + + + + + {/* Scheduled Time */} + +
+ + {formatScheduledTime(space.scheduledTime)} +
+
+ + {/* Status */} + + + {statusConfig.label} + + + + {/* Participant Count */} + + {space.participantCount !== null ? ( +
+ + + {space.participantCount} + +
+ ) : ( + + )} +
+ + {/* Flags */} + + {space.flags.includes("flagged") ? ( +
+ +
+ ) : ( + + )} +
+
+ ); + })} +
+
+
+ )} +
+
+
+ ); +}