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 }, + ], + }); +}