import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { SearchIcon } from 'lucide-react'; import { useMemo, useState } from 'react'; import { CartesianGrid, ComposedChart, Line, ResponsiveContainer, XAxis, YAxis, } from 'recharts'; import { ChartTooltipHeader, ChartTooltipItem, createChartTooltip, } from '@/components/charts/chart-tooltip'; import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; import { OverviewRange } from '@/components/overview/overview-range'; import { OverviewWidgetTable } from '@/components/overview/overview-widget-table'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { GscCannibalization } from '@/components/page/gsc-cannibalization'; import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark'; import { GscPositionChart } from '@/components/page/gsc-position-chart'; import { PagesInsights } from '@/components/page/pages-insights'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; import { Pagination } from '@/components/pagination'; import { useYAxisProps, X_AXIS_STYLE_PROPS, } from '@/components/report-chart/common/axis'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { Skeleton } from '@/components/skeleton'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useAppParams } from '@/hooks/use-app-params'; import { useTRPC } from '@/integrations/trpc/react'; import { pushModal } from '@/modals'; import { getChartColor } from '@/utils/theme'; import { createProjectTitle } from '@/utils/title'; export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({ component: SeoPage, head: () => ({ meta: [{ title: createProjectTitle('SEO') }], }), }); interface GscChartData { date: string; clicks: number; impressions: number; } const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip< GscChartData, Record >(({ data }) => { const item = data[0]; if (!item) { return null; } return ( <>
{item.date}
Clicks {item.clicks.toLocaleString()}
Impressions {item.impressions.toLocaleString()}
); }); function SeoPage() { const { projectId, organizationId } = useAppParams(); const trpc = useTRPC(); const navigate = useNavigate(); const { range, startDate, endDate, interval } = useOverviewOptions(); const dateInput = { range, interval, startDate, endDate, }; const connectionQuery = useQuery( trpc.gsc.getConnection.queryOptions({ projectId }) ); const connection = connectionQuery.data; const isConnected = connection?.siteUrl; const overviewQuery = useQuery( trpc.gsc.getOverview.queryOptions( { projectId, ...dateInput, interval: interval ?? 'day' }, { enabled: !!isConnected } ) ); const pagesQuery = useQuery( trpc.gsc.getPages.queryOptions( { projectId, ...dateInput, limit: 50 }, { enabled: !!isConnected } ) ); const queriesQuery = useQuery( trpc.gsc.getQueries.queryOptions( { projectId, ...dateInput, limit: 50 }, { enabled: !!isConnected } ) ); const searchEnginesQuery = useQuery( trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput }) ); const aiEnginesQuery = useQuery( trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput }) ); const previousOverviewQuery = useQuery( trpc.gsc.getPreviousOverview.queryOptions( { projectId, ...dateInput, interval: interval ?? 'day' }, { enabled: !!isConnected } ) ); const [pagesPage, setPagesPage] = useState(0); const [queriesPage, setQueriesPage] = useState(0); const pageSize = 15; const [pagesSearch, setPagesSearch] = useState(''); const [queriesSearch, setQueriesSearch] = useState(''); const pages = pagesQuery.data ?? []; const queries = queriesQuery.data ?? []; const filteredPages = useMemo(() => { if (!pagesSearch.trim()) { return pages; } const q = pagesSearch.toLowerCase(); return pages.filter((row) => { return String(row.page).toLowerCase().includes(q); }); }, [pages, pagesSearch]); const filteredQueries = useMemo(() => { if (!queriesSearch.trim()) { return queries; } const q = queriesSearch.toLowerCase(); return queries.filter((row) => { return String(row.query).toLowerCase().includes(q); }); }, [queries, queriesSearch]); const paginatedPages = useMemo( () => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize), [filteredPages, pagesPage, pageSize] ); const paginatedQueries = useMemo( () => filteredQueries.slice( queriesPage * pageSize, (queriesPage + 1) * pageSize ), [filteredQueries, queriesPage, pageSize] ); const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1; const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1; if (connectionQuery.isLoading) { return (
); } if (!isConnected) { return ( ); } const overview = overviewQuery.data ?? []; const prevOverview = previousOverviewQuery.data ?? []; const sumOverview = (rows: typeof overview) => rows.reduce( (acc, row) => ({ clicks: acc.clicks + row.clicks, impressions: acc.impressions + row.impressions, ctr: acc.ctr + row.ctr, position: acc.position + row.position, }), { clicks: 0, impressions: 0, ctr: 0, position: 0 } ); const totals = sumOverview(overview); const prevTotals = sumOverview(prevOverview); const n = Math.max(overview.length, 1); const pn = Math.max(prevOverview.length, 1); return ( } description={`Search performance for ${connection.siteUrl}`} title="SEO" />
({ current: r.clicks, date: r.date }))} id="clicks" isLoading={overviewQuery.isLoading} label="Clicks" metric={{ current: totals.clicks, previous: prevTotals.clicks }} /> ({ current: r.impressions, date: r.date, }))} id="impressions" isLoading={overviewQuery.isLoading} label="Impressions" metric={{ current: totals.impressions, previous: prevTotals.impressions, }} /> ({ current: r.ctr * 100, date: r.date, }))} id="ctr" isLoading={overviewQuery.isLoading} label="Avg CTR" metric={{ current: (totals.ctr / n) * 100, previous: (prevTotals.ctr / pn) * 100, }} unit="%" /> ({ current: r.position, date: r.date, }))} id="position" inverted isLoading={overviewQuery.isLoading} label="Avg Position" metric={{ current: totals.position / n, previous: prevTotals.position / pn, }} />
p.clicks), 1)} onNextPage={() => setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1)) } onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))} onRowClick={(value) => pushModal('PageDetails', { type: 'page', projectId, value }) } onSearchChange={(v) => { setPagesSearch(v); setPagesPage(0); }} pageCount={pagesPageCount} pageIndex={pagesPage} pageSize={pageSize} rows={paginatedPages} searchPlaceholder="Search pages" searchValue={pagesSearch} title="Top pages" totalCount={filteredPages.length} /> q.clicks), 1)} onNextPage={() => setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1)) } onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))} onRowClick={(value) => pushModal('PageDetails', { type: 'query', projectId, value }) } onSearchChange={(v) => { setQueriesSearch(v); setQueriesPage(0); }} pageCount={queriesPageCount} pageIndex={queriesPage} pageSize={pageSize} rows={paginatedQueries} searchPlaceholder="Search queries" searchValue={queriesSearch} title="Top queries" totalCount={filteredQueries.length} />
); } function TrafficSourceWidget({ title, engines, total, previousTotal, isLoading, emptyMessage, }: { title: string; engines: Array<{ name: string; sessions: number }>; total: number; previousTotal: number; isLoading: boolean; emptyMessage: string; }) { const displayed = engines.length > 8 ? [ ...engines.slice(0, 7), { name: 'Others', sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0), }, ] : engines.slice(0, 8); const max = displayed[0]?.sessions ?? 1; const pctChange = previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null; return (

{title}

{!isLoading && total > 0 && (
{total.toLocaleString()} {pctChange !== null && ( = 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`} > {pctChange >= 0 ? '+' : ''} {pctChange.toFixed(1)}% )}
)}
{isLoading && [1, 2, 3, 4].map((i) => (
))} {!isLoading && engines.length === 0 && (

{emptyMessage}

)} {!isLoading && displayed.map((engine) => { const pct = total > 0 ? (engine.sessions / total) * 100 : 0; const barPct = (engine.sessions / max) * 100; return (
{engine.name !== 'Others' && ( )} {engine.name.replace(/\..+$/, '')} {engine.sessions.toLocaleString()} {pct.toFixed(0)}%
); })}
); } function SearchEngines(props: { engines: Array<{ name: string; sessions: number }>; total: number; previousTotal: number; isLoading: boolean; }) { return ( ); } function AiEngines(props: { engines: Array<{ name: string; sessions: number }>; total: number; previousTotal: number; isLoading: boolean; }) { return ( ); } function GscChart({ data, isLoading, }: { data: Array<{ date: string; clicks: number; impressions: number }>; isLoading: boolean; }) { const color = getChartColor(0); const yAxisProps = useYAxisProps(); return (

Clicks & Impressions

{isLoading ? ( ) : ( v.slice(5)} type="category" /> )}
); } interface GscTableRow { clicks: number; impressions: number; ctr: number; position: number; [key: string]: string | number; } function GscTable({ title, rows, keyField, keyLabel, maxClicks, isLoading, onRowClick, searchValue, onSearchChange, searchPlaceholder, totalCount, pageIndex, pageSize, pageCount, onPreviousPage, onNextPage, }: { title: string; rows: GscTableRow[]; keyField: string; keyLabel: string; maxClicks: number; isLoading: boolean; onRowClick?: (value: string) => void; searchValue?: string; onSearchChange?: (value: string) => void; searchPlaceholder?: string; totalCount?: number; pageIndex?: number; pageSize?: number; pageCount?: number; onPreviousPage?: () => void; onNextPage?: () => void; }) { const showPagination = totalCount != null && pageSize != null && pageCount != null && onPreviousPage != null && onNextPage != null && pageIndex != null; const canPreviousPage = (pageIndex ?? 0) > 0; const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1; const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0; const rangeEnd = Math.min( (pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0), totalCount ?? 0 ); if (isLoading) { return (

{title}

, }, { name: 'Clicks', width: '70px', render: () => , }, { name: 'Impr.', width: '70px', render: () => , }, { name: 'CTR', width: '60px', render: () => , }, { name: 'Pos.', width: '55px', render: () => , }, ]} data={[1, 2, 3, 4, 5]} getColumnPercentage={() => 0} keyExtractor={(i) => String(i)} />
); } return (

{title}

{showPagination && (
{totalCount === 0 ? '0 results' : `${rangeStart}-${rangeEnd} of ${totalCount}`}
)}
{onSearchChange != null && (
onSearchChange(e.target.value)} placeholder={searchPlaceholder ?? 'Search'} type="search" value={searchValue ?? ''} />
)}
); }, }, { name: 'Clicks', width: '70px', getSortValue: (item) => item.clicks, render(item) { return ( {item.clicks.toLocaleString()} ); }, }, { name: 'Impr.', width: '70px', getSortValue: (item) => item.impressions, render(item) { return ( {item.impressions.toLocaleString()} ); }, }, { name: 'CTR', width: '60px', getSortValue: (item) => item.ctr, render(item) { return ( {(item.ctr * 100).toFixed(1)}% ); }, }, { name: 'Pos.', width: '55px', getSortValue: (item) => item.position, render(item) { return ( {item.position.toFixed(1)} ); }, }, ]} data={rows} getColumnPercentage={(item) => item.clicks / maxClicks} keyExtractor={(item) => String(item[keyField])} />
); }