import { useEffect, useState } from "react"; import { keepPreviousData, queryOptions, useQuery, useQueryClient, } from "@tanstack/react-query"; import { Download, Loader2, Sheet } from "lucide-react"; import { toast } from "sonner"; import { TableExportMenu } from "@/client/components/table/TableBulkActionBar"; import { TablePagination } from "@/client/components/table/TablePagination"; import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard"; import { DimensionTable, exportDimensionRows, exportStriking, StrikingDistanceTable, TabButton, TotalsCards, type ExportTarget, type Tab, } from "@/client/features/search-performance/SearchPerformanceParts"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { exportSearchPerformanceTable, getSearchPerformanceReport, getSearchPerformanceTable, } from "@/serverFunctions/searchPerformance"; import { GSC_DEVICES, SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE, SEARCH_PERFORMANCE_PAGE_SIZES, SEARCH_PERFORMANCE_RANGES, type SearchPerformanceDateRange, type SearchPerformanceDevice, type SearchPerformanceTableDimension, } from "@/types/schemas/search-performance"; const RANGE_LABELS: Record = { last_7_days: "Last 7 days", last_28_days: "Last 28 days", last_3_months: "Last 3 months", }; const RANGE_OPTIONS = SEARCH_PERFORMANCE_RANGES.map((value) => ({ value, label: RANGE_LABELS[value], })); const DEVICE_LABELS: Record = { DESKTOP: "Desktop", MOBILE: "Mobile", TABLET: "Tablet", }; const DEVICE_OPTIONS = GSC_DEVICES.map((value) => ({ value, label: DEVICE_LABELS[value], })); // Sentinel for "no filter" in the selects; never sent to the server. const ALL = "ALL"; function isDateRange(value: string): value is SearchPerformanceDateRange { return SEARCH_PERFORMANCE_RANGES.some((option) => option === value); } function isDevice(value: string): value is SearchPerformanceDevice { return GSC_DEVICES.some((option) => option === value); } function tabDimension(tab: Tab): SearchPerformanceTableDimension { return tab === "pages" ? "page" : "query"; } type FilterInput = { dateRange: SearchPerformanceDateRange; device?: SearchPerformanceDevice; country?: string; }; // The server filter payload: drop device/country when set to the "ALL" sentinel. function buildFilterInput( range: SearchPerformanceDateRange, device: SearchPerformanceDevice | typeof ALL, country: string, ): FilterInput { return { dateRange: range, ...(device === ALL ? {} : { device }), ...(country === ALL ? {} : { country }), }; } // Single source for the paginated table query, shared by the live query and the // warm-on-connect prefetch so their key + fn can never drift apart. function tableQueryOptions( projectId: string, dimension: SearchPerformanceTableDimension, page: number, pageSize: number, filterInput: FilterInput, ) { return queryOptions({ queryKey: [ "searchPerformanceTable", projectId, dimension, page, pageSize, filterInput, ], queryFn: () => getSearchPerformanceTable({ data: { projectId, dimension, page, pageSize, ...filterInput }, }), }); } export function SearchPerformancePage({ projectId }: { projectId: string }) { const queryClient = useQueryClient(); const [range, setRange] = useState("last_28_days"); const [device, setDevice] = useState( ALL, ); const [country, setCountry] = useState(ALL); const [tab, setTab] = useState("striking"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState( SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE, ); // Any change to the query set (tab, filters, page size) restarts at page 1. useEffect(() => { setPage(1); }, [tab, range, device, country, pageSize]); const filterInput = buildFilterInput(range, device, country); const reportQuery = useQuery({ queryKey: ["searchPerformance", projectId, range, device, country], queryFn: () => getSearchPerformanceReport({ data: { projectId, ...filterInput } }), placeholderData: keepPreviousData, }); const report = reportQuery.data; const isTableTab = tab === "queries" || tab === "pages"; const dimension = tabDimension(tab); const tableQuery = useQuery({ ...tableQueryOptions(projectId, dimension, page, pageSize, filterInput), enabled: report?.connected === true && isTableTab, placeholderData: keepPreviousData, }); const tableData = tableQuery.data; const tableRows = tableData?.connected ? tableData.rows : []; const hasNextPage = tableData?.connected ? tableData.hasNextPage : false; // Warm the Queries tab (first page) as soon as the report connects so the tab // opens instantly instead of showing a spinner. Free first-party GSC data. useEffect(() => { if (report?.connected !== true) return; void queryClient.prefetchQuery( tableQueryOptions( projectId, "query", 1, SEARCH_PERFORMANCE_DEFAULT_PAGE_SIZE, buildFilterInput(range, device, country), ), ); }, [report?.connected, projectId, range, device, country, queryClient]); const handleExport = async (target: ExportTarget) => { if (!report?.connected) return; try { if (tab === "striking") { exportStriking(report, target); return; } const data = await exportSearchPerformanceTable({ data: { projectId, dimension, ...filterInput }, }); exportDimensionRows(dimension, data.rows, report.range, target); } catch (error) { toast.error(getStandardErrorMessage(error, "Export failed")); } }; return (

Search Performance

See your site's clicks, impressions, CTR, and position from Google Search Console.

{reportQuery.isPending ? (
Loading Search Console data…
) : reportQuery.isError ? (
{getStandardErrorMessage(reportQuery.error)}
) : !report?.connected ? (

Find your striking-distance keywords — queries ranking just off the top of page one, where a small improvement can win the most new clicks. Connect Search Console to see them.

) : ( <>
setTab("striking")} label={`Striking distance (${report.strikingDistance.length})`} /> setTab("queries")} label="Queries" /> setTab("pages")} label="Pages" />
{reportQuery.isFetching && !reportQuery.isPending ? ( ) : null} , onClick: () => void handleExport("sheets"), }, { label: "Download CSV", icon: , onClick: () => void handleExport("csv"), }, ]} />
{tab === "striking" ? ( ) : tableQuery.isPending ? (
Loading…
) : tableQuery.isError ? (
{getStandardErrorMessage(tableQuery.error)}
) : ( <>
)}
)}
); }