From dc2b028901ff002d9682446a9ed31efe5eb43065 Mon Sep 17 00:00:00 2001 From: tewulogb Date: Thu, 27 Aug 2026 13:19:28 +0100 Subject: [PATCH 1/3] feat: add admin search analytics page with stubbed backend (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the search analytics dashboard page showing query volume trends, click-through rates, top queries table, and zero-result content gap signals. Backend action is stubbed with tracked:false placeholders until analytics endpoints ship. Includes service contract and page state tests. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- __tests__/admin/SearchAnalyticsPage.test.jsx | 221 +++++++ .../admin-search-analytics.service.test.js | 101 +++ app/[locale]/admin/analytics/search/page.jsx | 584 ++++++++++++++++++ lib/actions/admin-search-analytics.js | 97 +++ 4 files changed, 1003 insertions(+) create mode 100644 __tests__/admin/SearchAnalyticsPage.test.jsx create mode 100644 __tests__/admin/admin-search-analytics.service.test.js create mode 100644 app/[locale]/admin/analytics/search/page.jsx create mode 100644 lib/actions/admin-search-analytics.js diff --git a/__tests__/admin/SearchAnalyticsPage.test.jsx b/__tests__/admin/SearchAnalyticsPage.test.jsx new file mode 100644 index 00000000..0b6ef4d9 --- /dev/null +++ b/__tests__/admin/SearchAnalyticsPage.test.jsx @@ -0,0 +1,221 @@ +/** + * Search analytics page — loading / data / error state tests (#326). + * ------------------------------------------------------------------- + * The page renders query volume charts, CTR trend, top queries table, + * and zero-result queries list. Metrics the platform does not instrument + * yet must render an explicit "Not tracked yet" placeholder instead of + * a silent zero. These tests mock the analytics service and assert the + * presentational branching only. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("@/lib/config/font.config", () => ({ + poppins_400: { className: "" }, + poppins_500: { className: "" }, + poppins_600: { className: "" }, +})); + +const serviceState = vi.hoisted(() => ({ current: null })); +vi.mock("@/lib/actions/admin-search-analytics", () => ({ + fetchSearchAnalytics: (...args) => serviceState.current(...args), +})); + +function makeAnalytics(overrides = {}) { + return { + generatedAt: "2026-08-25T00:00:00.000Z", + dateRange: { + from: "2026-07-26T00:00:00.000Z", + to: "2026-08-25T00:00:00.000Z", + }, + totals: { + totalQueries: { label: "Total Queries", value: 0, tracked: false }, + uniqueQueries: { label: "Unique Queries", value: 0, tracked: false }, + zeroResultQueries: { + label: "Zero-Result Queries", + value: 0, + tracked: false, + }, + avgClickThroughRate: { + label: "Avg. Click-Through Rate", + value: null, + tracked: false, + unit: "%", + }, + }, + topQueries: [], + zeroResultQueries: [], + weeklyTrends: [ + { week: "Week 1", queries: 0, zeroResults: 0, clickThroughRate: null }, + { week: "Week 2", queries: 0, zeroResults: 0, clickThroughRate: null }, + { week: "Week 3", queries: 0, zeroResults: 0, clickThroughRate: null }, + { week: "Week 4", queries: 0, zeroResults: 0, clickThroughRate: null }, + ], + ...overrides, + }; +} + +let SearchAnalyticsPage; +beforeEach(async () => { + if (!SearchAnalyticsPage) { + const mod = await import( + "@/app/[locale]/admin/analytics/search/page" + ); + SearchAnalyticsPage = mod.default; + } +}); + +describe("SearchAnalyticsPage — states", () => { + it("renders skeleton placeholders while loading", () => { + serviceState.current = () => new Promise(() => {}); + const { container } = render(); + expect(screen.getByText("Search Analytics")).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="skeleton"]') + ).not.toBeNull(); + }); + + it("renders summary stat cards when data loads", async () => { + serviceState.current = () => Promise.resolve(makeAnalytics()); + render(); + + expect( + await screen.findByText("Search Analytics") + ).toBeInTheDocument(); + expect(screen.getByText("Total Queries")).toBeInTheDocument(); + expect(screen.getByText("Unique Queries")).toBeInTheDocument(); + expect( + screen.getAllByText("Zero-Result Queries").length + ).toBeGreaterThanOrEqual(1); + expect( + screen.getByText("Avg. Click-Through Rate") + ).toBeInTheDocument(); + }); + + it("shows 'Not tracked yet' placeholders for untracked metrics", async () => { + serviceState.current = () => Promise.resolve(makeAnalytics()); + render(); + + await screen.findByText("Search Analytics"); + + const placeholders = screen.getAllByText(/not tracked yet/i); + // At least the 4 summary stat cards + charts + tables + expect(placeholders.length).toBeGreaterThanOrEqual(4); + }); + + it("renders the top queries and zero-result sections", async () => { + serviceState.current = () => Promise.resolve(makeAnalytics()); + render(); + + await screen.findByText("Search Analytics"); + expect(screen.getAllByText("Top Queries").length).toBeGreaterThanOrEqual(1); + expect( + screen.getAllByText("Zero-Result Queries").length + ).toBeGreaterThanOrEqual(1); + }); + + it("renders top queries data when available", async () => { + const data = makeAnalytics({ + topQueries: [ + { + query: "quran tafsir", + count: 142, + clickThroughRate: 67.5, + trend: 12.3, + }, + { + query: "arabic grammar", + count: 98, + clickThroughRate: 45.2, + trend: -5.1, + }, + ], + totals: { + totalQueries: { label: "Total Queries", value: 240, tracked: true }, + uniqueQueries: { label: "Unique Queries", value: 87, tracked: true }, + zeroResultQueries: { + label: "Zero-Result Queries", + value: 12, + tracked: true, + }, + avgClickThroughRate: { + label: "Avg. Click-Through Rate", + value: 56.3, + tracked: true, + unit: "%", + }, + }, + }); + serviceState.current = () => Promise.resolve(data); + render(); + + await screen.findByText("quran tafsir"); + expect(screen.getByText("arabic grammar")).toBeInTheDocument(); + expect(screen.getByText("142")).toBeInTheDocument(); + expect(screen.getByText("98")).toBeInTheDocument(); + expect(screen.getByText("67.5%")).toBeInTheDocument(); + expect(screen.getByText("45.2%")).toBeInTheDocument(); + expect(screen.getByText("240")).toBeInTheDocument(); + expect(screen.getByText("87")).toBeInTheDocument(); + expect(screen.getByText("56.3%")).toBeInTheDocument(); + }); + + it("renders zero-result queries data when available", async () => { + const data = makeAnalytics({ + zeroResultQueries: [ + { + query: "advanced nahw exercises", + count: 8, + lastSearched: "2026-08-20T10:00:00.000Z", + }, + ], + totals: { + totalQueries: { label: "Total Queries", value: 240, tracked: true }, + uniqueQueries: { label: "Unique Queries", value: 87, tracked: true }, + zeroResultQueries: { + label: "Zero-Result Queries", + value: 12, + tracked: true, + }, + avgClickThroughRate: { + label: "Avg. Click-Through Rate", + value: 56.3, + tracked: true, + unit: "%", + }, + }, + }); + serviceState.current = () => Promise.resolve(data); + render(); + + await screen.findByText(/advanced nahw exercises/); + expect(screen.getByText("8")).toBeInTheDocument(); + expect( + screen.getByText("advanced nahw exercises").closest("tr") + ).toBeTruthy(); + }); + + it("renders an error state when the analytics service fails", async () => { + serviceState.current = () => + Promise.reject(new Error("Service unavailable")); + render(); + + expect( + await screen.findByText(/failed to load search analytics/i) + ).toBeInTheDocument(); + expect(screen.getByText("Service unavailable")).toBeInTheDocument(); + }); + + it("renders weekly trends chart sections", async () => { + serviceState.current = () => Promise.resolve(makeAnalytics()); + render(); + + await screen.findByText("Search Analytics"); + expect( + screen.getByText("Weekly Query Volume") + ).toBeInTheDocument(); + expect( + screen.getByText("Click-Through Rate Trend") + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/admin/admin-search-analytics.service.test.js b/__tests__/admin/admin-search-analytics.service.test.js new file mode 100644 index 00000000..e271565e --- /dev/null +++ b/__tests__/admin/admin-search-analytics.service.test.js @@ -0,0 +1,101 @@ +/** + * Admin search-analytics service — contract tests (#326). + * ------------------------------------------------------------------ + * The service in `lib/actions/admin-search-analytics.js` is the seam the + * admin search-analytics page calls for the snapshot. These tests pin the + * resolved shape the UI depends on and, crucially, that metrics without + * backend instrumentation resolve with `tracked: false` so the page renders + * a "not tracked yet" placeholder instead of a silent zero. + */ +import { describe, it, expect } from "vitest"; +import { fetchSearchAnalytics } from "@/lib/actions/admin-search-analytics"; + +describe("fetchSearchAnalytics", () => { + it("resolves a snapshot with the documented shape", async () => { + const data = await fetchSearchAnalytics(); + expect(typeof data.generatedAt).toBe("string"); + expect(typeof data.dateRange).toBe("object"); + expect(typeof data.dateRange.from).toBe("string"); + expect(typeof data.dateRange.to).toBe("string"); + }); + + it("exposes all four total metrics with the correct shape", async () => { + const data = await fetchSearchAnalytics(); + for (const key of [ + "totalQueries", + "uniqueQueries", + "zeroResultQueries", + "avgClickThroughRate", + ]) { + expect(data.totals[key]).toBeDefined(); + expect(typeof data.totals[key].label).toBe("string"); + expect(typeof data.totals[key].tracked).toBe("boolean"); + } + expect(typeof data.totals.totalQueries.value).toBe("number"); + expect(typeof data.totals.uniqueQueries.value).toBe("number"); + expect(typeof data.totals.zeroResultQueries.value).toBe("number"); + // avgClickThroughRate may be null when not tracked + expect( + data.totals.avgClickThroughRate.value === null || + typeof data.totals.avgClickThroughRate.value === "number" + ).toBe(true); + }); + + it("marks all metrics as not tracked when backend is stubbed", async () => { + const data = await fetchSearchAnalytics(); + expect(data.totals.totalQueries.tracked).toBe(false); + expect(data.totals.uniqueQueries.tracked).toBe(false); + expect(data.totals.zeroResultQueries.tracked).toBe(false); + expect(data.totals.avgClickThroughRate.tracked).toBe(false); + }); + + it("provides an empty top queries list", async () => { + const data = await fetchSearchAnalytics(); + expect(Array.isArray(data.topQueries)).toBe(true); + expect(data.topQueries.length).toBe(0); + }); + + it("provides an empty zero-result queries list", async () => { + const data = await fetchSearchAnalytics(); + expect(Array.isArray(data.zeroResultQueries)).toBe(true); + expect(data.zeroResultQueries.length).toBe(0); + }); + + it("provides weekly trends with the correct shape", async () => { + const data = await fetchSearchAnalytics(); + expect(Array.isArray(data.weeklyTrends)).toBe(true); + expect(data.weeklyTrends.length).toBeGreaterThan(0); + for (const week of data.weeklyTrends) { + expect(typeof week.week).toBe("string"); + expect(typeof week.queries).toBe("number"); + expect(typeof week.zeroResults).toBe("number"); + expect( + week.clickThroughRate === null || + typeof week.clickThroughRate === "number" + ).toBe(true); + } + }); + + it("accepts custom date range parameters", async () => { + const from = "2026-01-01T00:00:00.000Z"; + const to = "2026-01-31T23:59:59.999Z"; + const data = await fetchSearchAnalytics({ from, to }); + expect(data.dateRange.from).toBe(from); + expect(data.dateRange.to).toBe(to); + }); + + it("falls back to default date range when parameters are omitted", async () => { + const data = await fetchSearchAnalytics(); + // The from date should be approximately 30 days ago + const fromDate = new Date(data.dateRange.from); + const toDate = new Date(data.dateRange.to); + const diffDays = Math.round((toDate - fromDate) / (1000 * 60 * 60 * 24)); + expect(diffDays).toBeGreaterThanOrEqual(29); + expect(diffDays).toBeLessThanOrEqual(31); + }); + + it("includes an avgClickThroughRate metric with unit %", async () => { + const data = await fetchSearchAnalytics(); + expect(data.totals.avgClickThroughRate.unit).toBe("%"); + }); +}); diff --git a/app/[locale]/admin/analytics/search/page.jsx b/app/[locale]/admin/analytics/search/page.jsx new file mode 100644 index 00000000..d52533f9 --- /dev/null +++ b/app/[locale]/admin/analytics/search/page.jsx @@ -0,0 +1,584 @@ +"use client"; + +import { useEffect, useState, useMemo } 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 { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +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 { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, +} from "@/components/ui/chart"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Line, + LineChart, +} from "recharts"; +import { + Search, + TrendingUp, + TrendingDown, + BarChart3, + Calendar, + MousePointerClick, + AlertTriangle, + ArrowUpRight, + ArrowDownRight, + Minus, + FileQuestion, + Eye, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config"; +import { fetchSearchAnalytics } from "@/lib/actions/admin-search-analytics"; + +const COLORS = { + queries: "#009900", + zeroResults: "#dc2626", + accent: "#265902", + ctr: "#00cc66", +}; + +const trendsChartConfig = { + queries: { label: "Queries", color: COLORS.queries }, + zeroResults: { label: "Zero Results", color: COLORS.zeroResults }, +}; + +const ctrChartConfig = { + clickThroughRate: { label: "Click-Through Rate", color: COLORS.ctr }, +}; + +function TrendBadge({ value }) { + if (value === null || value === undefined) return —; + + if (value > 0) { + return ( + + + {Math.abs(value).toFixed(1)}% + + ); + } + if (value < 0) { + return ( + + + {Math.abs(value).toFixed(1)}% + + ); + } + return ( + + + 0% + + ); +} + +function StatCard({ icon: Icon, label, metric }) { + return ( + + +
+
+

+ {label} +

+ {metric.tracked ? ( +

+ {metric.unit === "%" + ? metric.value !== null + ? `${metric.value.toFixed(1)}%` + : "—" + : metric.value.toLocaleString()} +

+ ) : ( +

+ — +

+ )} + {!metric.tracked && ( +

+ Not tracked yet +

+ )} +
+
+ +
+
+
+
+ ); +} + +function TopQueriesTable({ queries, tracked }) { + if (!tracked || queries.length === 0) { + return ( +
+
+ +
+
+
+

+ Top Queries +

+ + {tracked ? "No data yet" : "Not tracked yet"} + +
+

+ {tracked + ? "No search queries have been recorded in this period." + : "Search query events are not being recorded yet, so no top queries are available."} +

+
+
+ ); + } + + return ( + + + + # + Query + Searches + Click-Through Rate + Trend (WoW) + + + + {queries.map((q, index) => ( + + {index + 1} + + {q.query} + + + {q.count.toLocaleString()} + + + {q.clickThroughRate !== null ? `${q.clickThroughRate.toFixed(1)}%` : "—"} + + + + + + ))} + +
+ ); +} + +function ZeroResultQueriesList({ queries, tracked }) { + if (!tracked || queries.length === 0) { + return ( +
+
+ +
+
+
+

+ Zero-Result Queries +

+ + {tracked ? "No data yet" : "Not tracked yet"} + +
+

+ {tracked + ? "No zero-result queries have been recorded in this period." + : "Zero-result search events are not being recorded yet. This list will surface content gaps once tracking is enabled."} +

+
+
+ ); + } + + return ( + + + + # + Query + Searches + Last Searched + + + + {queries.map((q, index) => ( + + {index + 1} + + {q.query} + + + {q.count.toLocaleString()} + + + {new Date(q.lastSearched).toLocaleDateString()} + + + ))} + +
+ ); +} + +export default function SearchAnalyticsPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [timeRange, setTimeRange] = useState("30d"); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + const now = new Date(); + let from; + + switch (timeRange) { + case "7d": + from = new Date(now); + from.setDate(from.getDate() - 7); + break; + case "14d": + from = new Date(now); + from.setDate(from.getDate() - 14); + break; + case "30d": + from = new Date(now); + from.setDate(from.getDate() - 30); + break; + case "90d": + from = new Date(now); + from.setDate(from.getDate() - 90); + break; + case "1y": + from = new Date(now); + from.setFullYear(from.getFullYear() - 1); + break; + default: + from = new Date(now); + from.setDate(from.getDate() - 30); + } + + fetchSearchAnalytics({ from: from.toISOString(), to: now.toISOString() }) + .then((analytics) => { + if (active) { + setData(analytics); + setLoading(false); + } + }) + .catch((err) => { + if (active) { + setError(err?.message || "Failed to load search analytics"); + setLoading(false); + } + }); + + return () => { + active = false; + }; + }, [timeRange]); + + if (loading) { + return ( + + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ + +
+ + +
+ ); + } + + if (error) { + return ( + + + + + ); + } + + const { totals, topQueries, zeroResultQueries, weeklyTrends } = data; + + return ( + + + + + + + + Last 7 Days + Last 14 Days + Last 30 Days + Last 90 Days + Last Year + + + } + /> + + {/* Summary Stats */} +
+ + + + +
+ + {/* Weekly Trends Charts */} +
+ {/* Query Volume & Zero Results Trend */} + + + + + Weekly Query Volume + + + Total searches and zero-result queries over time + + + + {weeklyTrends.some((w) => w.queries > 0 || w.zeroResults > 0) ? ( + + + + + + } + /> + + + } /> + + + ) : ( +
+
+ +
+
+ + Not tracked yet + +

+ Search event tracking is not enabled yet. Once enabled, weekly query volume and zero-result trends will appear here. +

+
+
+ )} +
+
+ + {/* Click-Through Rate Trend */} + + + + + Click-Through Rate Trend + + + Average click-through rate across search results per week + + + + {weeklyTrends.some((w) => w.clickThroughRate !== null) ? ( + + + + + `${val}%`} + /> + + value !== null ? `${Number(value).toFixed(1)}%` : "—" + } + /> + } + /> + + } /> + + + ) : ( +
+
+ +
+
+ + Not tracked yet + +

+ Click-through rate data is not available yet. This chart will populate once search-result interactions are instrumented. +

+
+
+ )} +
+
+
+ + {/* Top Queries Table */} + + + + + Top Queries + + + Most frequently searched terms with click-through rates and week-over-week trends + + + + + + + + {/* Zero-Result Queries — Content Gap Signal */} + + + + + Zero-Result Queries + + + Searches that returned no results — these signal content gaps that can inform acquisition decisions + + + + + + +
+ ); +} diff --git a/lib/actions/admin-search-analytics.js b/lib/actions/admin-search-analytics.js new file mode 100644 index 00000000..0d2a029f --- /dev/null +++ b/lib/actions/admin-search-analytics.js @@ -0,0 +1,97 @@ +/** + * Admin search-analytics — top queries, zero-result queries, WoW trends. + * --------------------------------------------------------------------------- + * **STUBBED.** Resolves a representative search-analytics snapshot so the admin + * search-analytics page (#326) can be built and reviewed before the backend + * analytics endpoints ship. Every metric carries an explicit `tracked` flag: + * metrics the platform does not instrument yet resolve with `tracked: false` + * so the UI renders an explicit "not tracked yet" placeholder instead of a + * silent zero (see the issue's acceptance criteria). + * + * TODO(backend): GET /api/admin/analytics/search?from=&to= + * - Auth: requires a super-admin session token (server-side tier check). + * - 200 → the search-analytics shape documented below. + * + * Search analytics shape: + * { + * generatedAt: string, + * dateRange: { from: string, to: string }, + * totals: { + * totalQueries: { label: string, value: number, tracked: boolean }, + * uniqueQueries: { label: string, value: number, tracked: boolean }, + * zeroResultQueries: { label: string, value: number, tracked: boolean }, + * avgClickThroughRate: { label: string, value: number | null, tracked: boolean, unit: string }, + * }, + * topQueries: [ + * { query: string, count: number, clickThroughRate: number, trend: number | null }, + * ], + * zeroResultQueries: [ + * { query: string, count: number, lastSearched: string }, + * ], + * weeklyTrends: [ + * { week: string, queries: number, zeroResults: number, clickThroughRate: number | null }, + * ], + * } + */ + +function withResolved(value) { + return Promise.resolve(value); +} + +/** + * Fetch the platform-wide search analytics snapshot. + * + * TODO(backend): + * return axiosInstance + * .get("/api/admin/analytics/search", { params: { from, to } }) + * .then((res) => res.data); + * + * @param {object} [options] + * @param {string} [options.from] - ISO date string for the start of the range + * @param {string} [options.to] - ISO date string for the end of the range + * @returns {Promise} the search-analytics snapshot documented above. + */ +export async function fetchSearchAnalytics({ from, to } = {}) { + const now = new Date(); + const thirtyDaysAgo = new Date(now); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + return withResolved({ + generatedAt: now.toISOString(), + dateRange: { + from: from || thirtyDaysAgo.toISOString(), + to: to || now.toISOString(), + }, + totals: { + totalQueries: { + label: "Total Queries", + value: 0, + tracked: false, + }, + uniqueQueries: { + label: "Unique Queries", + value: 0, + tracked: false, + }, + zeroResultQueries: { + label: "Zero-Result Queries", + value: 0, + tracked: false, + }, + avgClickThroughRate: { + label: "Avg. Click-Through Rate", + value: null, + tracked: false, + unit: "%", + }, + }, + topQueries: [], + zeroResultQueries: [], + weeklyTrends: [ + { week: "Week 1", queries: 0, zeroResults: 0, clickThroughRate: null }, + { week: "Week 2", queries: 0, zeroResults: 0, clickThroughRate: null }, + { week: "Week 3", queries: 0, zeroResults: 0, clickThroughRate: null }, + { week: "Week 4", queries: 0, zeroResults: 0, clickThroughRate: null }, + ], + }); +} From df88ad9416f2c54d6b3a9d8ae6009b84593b0f28 Mon Sep 17 00:00:00 2001 From: tewulogb Date: Thu, 27 Aug 2026 14:02:55 +0100 Subject: [PATCH 2/3] fix: resolve pre-existing build errors in admin pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix duplicate/corrupted JSX structures from bad merges in: - audit-logs: removed duplicate table headers, loading states, and pagination buttons - reconciliation: removed duplicate button block and duplicate tx.txHash section - reports: removed duplicate table headers, loading states, and dropdown menus Also fix missing semicolon in lib/admin/messages/common.js (unescaped quotes). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- app/[locale]/admin/audit-logs/page.jsx | 272 ++++-------- app/[locale]/admin/reconciliation/page.jsx | 144 +------ app/[locale]/admin/reports/page.jsx | 459 ++++++++++----------- lib/admin/messages/common.js | 2 +- 4 files changed, 327 insertions(+), 550 deletions(-) diff --git a/app/[locale]/admin/audit-logs/page.jsx b/app/[locale]/admin/audit-logs/page.jsx index 2b2323cc..9e56ecda 100644 --- a/app/[locale]/admin/audit-logs/page.jsx +++ b/app/[locale]/admin/audit-logs/page.jsx @@ -46,6 +46,7 @@ import { Calendar as CalendarIcon, RefreshCw, ExternalLink, + Loader2, } from "lucide-react"; import { TableSkeleton } from "@/components/admin/table-skeleton"; import { TableEmptyState } from "@/components/admin/table-empty-state"; @@ -340,181 +341,107 @@ export default function AuditLogsPage() { {error ? ( ) : ( -
-
- {/* Desktop Table */} -
- - - - Timestamp - Admin Actor - Action - Target - Summary - IP Address -
-
- - - Timestamp - Admin Actor - Action - Target - Summary - IP Address - - - +
+ {/* Desktop Table */} +
+
+ + + Timestamp + Admin Actor + Action + Target + Summary + IP Address + + + + {loading ? ( + + ) : logs.length === 0 ? ( + + ) : ( + logs.map((log) => { + const category = ACTION_CATEGORIES[log.category]; + const CategoryIcon = category?.icon || FileText; + return ( + + {formatTimestamp(log.timestamp)} + +
+ + {log.actor} +
+
+ +
+ + {log.action} +
+
+ + + {log.target.name} + + + + {log.summary} + {log.ip} +
+ ); + }) + )} +
+
+
+ + {/* Mobile Card View */} +
{loading ? ( - - - - - - - {loading ? ( - - ) : logs.length === 0 ? ( - - ) : ( +
+ +
+ ) : logs.length === 0 ? ( +
+ No audit logs found matching your filters +
+ ) : ( logs.map((log) => { const category = ACTION_CATEGORIES[log.category]; const CategoryIcon = category?.icon || FileText; return ( - - - - - - - - ) : logs.length === 0 ? ( - - - No audit logs found matching your filters - - - ) : ( - logs.map((log) => { - const category = ACTION_CATEGORIES[log.category]; - const CategoryIcon = category?.icon || FileText; - return ( - - {formatTimestamp(log.timestamp)} - -
- - {log.actor} -
-
- -
- - {log.action} -
-
- - - {log.target.name} - - - - {log.summary} - {log.ip} -
- ); - }) - )} -
- -
- - {/* Mobile Card View */} -
- {loading ? ( -
- -
- ) : logs.length === 0 ? ( -
- No audit logs found matching your filters -
- ) : ( - logs.map((log) => { - const category = ACTION_CATEGORIES[log.category]; - const CategoryIcon = category?.icon || FileText; - return ( -
-
-
- - {log.action} -
- - {formatTimestamp(log.timestamp)} - -
-
- - {log.actor} - → - - {log.target.name} - -
-
-

{log.summary}

- {log.ip} -
-
- ); - }) - )} -
- - -
- - {log.actor} +
+
+
+ + {log.action}
- - -
- - - {log.action} - -
-
- - + + {formatTimestamp(log.timestamp)} + +
+
+ + {log.actor} + → + {log.target.name} -
+
+

{log.summary}

+ {log.ip} +
+
); }) )} - - -
+
+
)} {/* Pagination */} @@ -529,23 +456,6 @@ export default function AuditLogsPage() { Previous - diff --git a/app/[locale]/admin/reconciliation/page.jsx b/app/[locale]/admin/reconciliation/page.jsx index 3d274f7d..9894afae 100644 --- a/app/[locale]/admin/reconciliation/page.jsx +++ b/app/[locale]/admin/reconciliation/page.jsx @@ -286,30 +286,17 @@ export default function PayoutReconciliationPage() {
- - - {transactions.length > 0 && ( - {transactions.length > 0 && ( @@ -475,119 +462,18 @@ export default function PayoutReconciliationPage() {
- {/* Mobile Card View */} -
+ {/* Mobile Card View */} +
{transactions.map((tx) => { const statusConfig = STATUS_CONFIG[tx.status]; const StatusIcon = statusConfig.icon; diff --git a/app/[locale]/admin/reports/page.jsx b/app/[locale]/admin/reports/page.jsx index af3e1f47..1d3db76c 100644 --- a/app/[locale]/admin/reports/page.jsx +++ b/app/[locale]/admin/reports/page.jsx @@ -69,6 +69,7 @@ import { Clock, AlertTriangle, MessageSquare, + Loader2, } from "lucide-react"; import { TableSkeleton } from "@/components/admin/table-skeleton"; import { TableEmptyState } from "@/components/admin/table-empty-state"; @@ -463,62 +464,188 @@ export default function UnifiedReportsPage() { {error ? ( ) : ( -
- - -
- {/* Desktop Table */} -
-
- -
- - - Reporter - Target - Reason - Age - Status - Assignee - - - - +
+ {/* Desktop Table */} +
+
+ + + Reporter + Target + Reason + Age + Status + Assignee + + + + + {loading ? ( + + ) : reports.length === 0 ? ( + + ) : ( + reports.map((report, index) => { + const contentType = CONTENT_TYPES[report.target.type]; + const ContentIcon = contentType?.icon || Flag; + const status = REPORT_STATUSES[report.status]; + const isSelected = index === selectedIndex; + + return ( + setSelectedIndex(index)} + > + {/* Reporter */} + +
+ + + + {report.reporter.name.charAt(0)} + + + + {report.reporter.name} + +
+
+ + {/* Target */} + + +
+ +
+
+

+ {report.target.name} +

+

+ {contentType?.label} +

+
+
+ + {/* Reason */} + + + {REASON_CATEGORIES[report.reason]} + + + + {/* Age */} + +
+ + {formatReportAge(report.createdAt)} +
+
+ + {/* Status */} + + + {status?.label} + + + + {/* Assignee */} + + {report.assignee ? ( +
+ + + + {report.assignee.name.charAt(0)} + + + {report.assignee.name} +
+ ) : ( + Unassigned + )} +
+ + {/* Actions */} + + + + + + + + + + View Target + + + + handleStatusChange(report.id, "in-review")}> + + Mark In Review + + handleStatusChange(report.id, "resolved")}> + + Mark Resolved + + { + setDismissTarget(report); + setIsDismissDialogOpen(true); + }} + > + + Dismiss + + + + +
+ ); + }) + )} +
+
+
+ + {/* Mobile Card View */} +
{loading ? ( - - Reporter - Target - Reason - Age - Status - Assignee - - - - - - - {loading ? ( - - ) : reports.length === 0 ? ( - - ) : ( +
+ +
) : reports.length === 0 ? ( - - Reporter - Target - Reason - Age - Status - Assignee - - +
+ No reports found matching your filters +
) : ( reports.map((report, index) => { const contentType = CONTENT_TYPES[report.target.type]; @@ -527,139 +654,32 @@ export default function UnifiedReportsPage() { const isSelected = index === selectedIndex; return ( - setSelectedIndex(index)} > - {/* Reporter */} - -
- +
+
+ - + {report.reporter.name.charAt(0)} - - {report.reporter.name} - -
- - - {/* Target */} - - -
- -
-
-

- {report.target.name} -

-

- {contentType?.label} -

-
-
- - {/* Reason */} - - - {REASON_CATEGORIES[report.reason]} - - - - {/* Age */} - -
- - {formatReportAge(report.createdAt)} + {report.reporter.name}
-
- - {/* Status */} - - - {status?.label} - - - - {/* Assignee */} - - {report.assignee ? ( -
- - - - {report.reporter.name.charAt(0)} - - - {report.reporter.name} -
-
- - -
- -
-
-

{report.target.name}

-

{contentType?.label}

-
- - -
- - {REASON_CATEGORIES[report.reason]} - - -
- - {formatReportAge(report.createdAt)} -
-
- - {status?.label} - - - {report.assignee ? ( -
- - {report.assignee.name.charAt(0)} - - {report.assignee.name} -
- ) : ( - Unassigned - )} -
- +
+ + {status?.label} + - @@ -678,79 +698,40 @@ export default function UnifiedReportsPage() { Mark Resolved - handleStatusChange(report.id, "dismissed")}> + { + setDismissTarget(report); + setIsDismissDialogOpen(true); + }} + > Dismiss - - - ); - }) - )} - - -
- - {/* Actions */} - - - - - - - - - - View Target - - - - handleStatusChange(report.id, "in-review")}> - - Mark In Review - - handleStatusChange(report.id, "resolved")}> - - Mark Resolved - - { - setDismissTarget(report); - setIsDismissDialogOpen(true); - }} - > - - Dismiss - - - -
-
-
- -
-
-

{report.target.name}

- - -
-
- {REASON_CATEGORIES[report.reason]} - {report.assignee && ( - → {report.assignee.name} - )} +
+
+ +
+ +
+

{report.target.name}

+ + +
+
+ {REASON_CATEGORIES[report.reason]} + {report.assignee && ( + → {report.assignee.name} + )} +
-
- ); - }) - )} + ); + }) + )} +
- )} {/* Pagination */} diff --git a/lib/admin/messages/common.js b/lib/admin/messages/common.js index 7fea2383..3a9e7bbc 100644 --- a/lib/admin/messages/common.js +++ b/lib/admin/messages/common.js @@ -98,7 +98,7 @@ export const ERROR_TIMEOUT = "Request timed out. Please try again."; export const EMPTY_NO_DATA = "No data available"; export const EMPTY_NO_RESULTS = "No results found"; export const EMPTY_NO_ITEMS = "No {items} yet"; -export const EMPTY_SEARCH_NO_RESULTS = "No results for "{query}""; +export const EMPTY_SEARCH_NO_RESULTS = "No results for \"{query}\""; // ───────────────────────────────────────────────────────────────────────────── // Pagination From 8ba48d3f684803b651e84e6cb5c0188c63f297c1 Mon Sep 17 00:00:00 2001 From: tewulogb Date: Thu, 27 Aug 2026 14:18:21 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20resolve=20CI=20failures=20=E2=80=94?= =?UTF-8?q?=20a11y,=20lighthouse,=20and=20lock=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix lighthouserc.json: change urlList → url for lighthouse-ci - Fix a11y label/control associations in audit-logs, reconciliation, GlobalTransactionExplorer (add htmlFor + id) - Add role/tabIndex/onKeyDown to clickable div in reports page - Regenerate package-lock.json to resolve dependency mismatches 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- app/[locale]/admin/audit-logs/page.jsx | 12 +- app/[locale]/admin/reconciliation/page.jsx | 4 +- app/[locale]/admin/reports/page.jsx | 8 + .../admin/GlobalTransactionExplorer.jsx | 8 +- lighthouserc.json | 2 +- package-lock.json | 7828 +++++------------ 6 files changed, 2051 insertions(+), 5811 deletions(-) diff --git a/app/[locale]/admin/audit-logs/page.jsx b/app/[locale]/admin/audit-logs/page.jsx index 9e56ecda..b0493fc5 100644 --- a/app/[locale]/admin/audit-logs/page.jsx +++ b/app/[locale]/admin/audit-logs/page.jsx @@ -247,9 +247,9 @@ export default function AuditLogsPage() {
{/* Actor Filter */}
- + - + @@ -286,10 +286,10 @@ export default function AuditLogsPage() { {/* Date Range */}
- + -