822 lines
25 KiB
TypeScript
822 lines
25 KiB
TypeScript
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<string, unknown>
|
|
>(({ data }) => {
|
|
const item = data[0];
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
return (
|
|
<>
|
|
<ChartTooltipHeader>
|
|
<div>{item.date}</div>
|
|
</ChartTooltipHeader>
|
|
<ChartTooltipItem color={getChartColor(0)}>
|
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
|
<span>Clicks</span>
|
|
<span>{item.clicks.toLocaleString()}</span>
|
|
</div>
|
|
</ChartTooltipItem>
|
|
<ChartTooltipItem color={getChartColor(1)}>
|
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
|
<span>Impressions</span>
|
|
<span>{item.impressions.toLocaleString()}</span>
|
|
</div>
|
|
</ChartTooltipItem>
|
|
</>
|
|
);
|
|
});
|
|
|
|
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 (
|
|
<PageContainer>
|
|
<PageHeader description="Google Search Console data" title="SEO" />
|
|
<div className="mt-8 space-y-4">
|
|
<Skeleton className="h-64 w-full" />
|
|
<Skeleton className="h-96 w-full" />
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
if (!isConnected) {
|
|
return (
|
|
<FullPageEmptyState
|
|
className="pt-[20vh]"
|
|
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
|
|
icon={SearchIcon}
|
|
title="No SEO data yet"
|
|
>
|
|
<Button
|
|
onClick={() =>
|
|
navigate({
|
|
to: '/$organizationId/$projectId/settings/gsc',
|
|
params: { organizationId, projectId },
|
|
})
|
|
}
|
|
>
|
|
Connect Google Search Console
|
|
</Button>
|
|
</FullPageEmptyState>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<PageContainer>
|
|
<PageHeader
|
|
actions={
|
|
<>
|
|
<OverviewRange />
|
|
<OverviewInterval />
|
|
</>
|
|
}
|
|
description={`Search performance for ${connection.siteUrl}`}
|
|
title="SEO"
|
|
/>
|
|
|
|
<div className="mt-8 space-y-8">
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
|
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
|
|
<OverviewMetricCard
|
|
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
|
|
id="clicks"
|
|
isLoading={overviewQuery.isLoading}
|
|
label="Clicks"
|
|
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
|
|
/>
|
|
<OverviewMetricCard
|
|
data={overview.map((r) => ({
|
|
current: r.impressions,
|
|
date: r.date,
|
|
}))}
|
|
id="impressions"
|
|
isLoading={overviewQuery.isLoading}
|
|
label="Impressions"
|
|
metric={{
|
|
current: totals.impressions,
|
|
previous: prevTotals.impressions,
|
|
}}
|
|
/>
|
|
<OverviewMetricCard
|
|
data={overview.map((r) => ({
|
|
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="%"
|
|
/>
|
|
<OverviewMetricCard
|
|
data={overview.map((r) => ({
|
|
current: r.position,
|
|
date: r.date,
|
|
}))}
|
|
id="position"
|
|
inverted
|
|
isLoading={overviewQuery.isLoading}
|
|
label="Avg Position"
|
|
metric={{
|
|
current: totals.position / n,
|
|
previous: prevTotals.position / pn,
|
|
}}
|
|
/>
|
|
</div>
|
|
<SearchEngines
|
|
engines={searchEnginesQuery.data?.engines ?? []}
|
|
isLoading={searchEnginesQuery.isLoading}
|
|
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
|
|
total={searchEnginesQuery.data?.total ?? 0}
|
|
/>
|
|
<AiEngines
|
|
engines={aiEnginesQuery.data?.engines ?? []}
|
|
isLoading={aiEnginesQuery.isLoading}
|
|
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
|
|
total={aiEnginesQuery.data?.total ?? 0}
|
|
/>
|
|
</div>
|
|
|
|
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<GscPositionChart
|
|
data={overview}
|
|
isLoading={overviewQuery.isLoading}
|
|
/>
|
|
<GscCtrBenchmark
|
|
data={pagesQuery.data ?? []}
|
|
isLoading={pagesQuery.isLoading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
|
<GscTable
|
|
isLoading={pagesQuery.isLoading}
|
|
keyField="page"
|
|
keyLabel="Page"
|
|
maxClicks={Math.max(...paginatedPages.map((p) => 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}
|
|
/>
|
|
<GscTable
|
|
isLoading={queriesQuery.isLoading}
|
|
keyField="query"
|
|
keyLabel="Query"
|
|
maxClicks={Math.max(...paginatedQueries.map((q) => 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}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<GscCannibalization
|
|
endDate={endDate ?? undefined}
|
|
interval={interval ?? 'day'}
|
|
projectId={projectId}
|
|
range={range}
|
|
startDate={startDate ?? undefined}
|
|
/>
|
|
<PagesInsights projectId={projectId} />
|
|
</div>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="card overflow-hidden">
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<h3 className="font-medium text-sm">{title}</h3>
|
|
{!isLoading && total > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium font-mono text-sm tabular-nums">
|
|
{total.toLocaleString()}
|
|
</span>
|
|
{pctChange !== null && (
|
|
<span
|
|
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
|
>
|
|
{pctChange >= 0 ? '+' : ''}
|
|
{pctChange.toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2">
|
|
{isLoading &&
|
|
[1, 2, 3, 4].map((i) => (
|
|
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
|
|
<div className="size-4 animate-pulse rounded-sm bg-muted" />
|
|
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
|
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
))}
|
|
{!isLoading && engines.length === 0 && (
|
|
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
|
|
{emptyMessage}
|
|
</p>
|
|
)}
|
|
{!isLoading &&
|
|
displayed.map((engine) => {
|
|
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
|
|
const barPct = (engine.sessions / max) * 100;
|
|
return (
|
|
<div className="relative px-4 py-2.5" key={engine.name}>
|
|
<div
|
|
className="absolute inset-y-0 left-0 bg-muted/50"
|
|
style={{ width: `${barPct}%` }}
|
|
/>
|
|
<div className="relative flex items-center gap-2">
|
|
{engine.name !== 'Others' && (
|
|
<SerieIcon
|
|
className="size-3.5 shrink-0 rounded-sm"
|
|
name={engine.name}
|
|
/>
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-xs capitalize">
|
|
{engine.name.replace(/\..+$/, '')}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-xs tabular-nums">
|
|
{engine.sessions.toLocaleString()}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
|
{pct.toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SearchEngines(props: {
|
|
engines: Array<{ name: string; sessions: number }>;
|
|
total: number;
|
|
previousTotal: number;
|
|
isLoading: boolean;
|
|
}) {
|
|
return (
|
|
<TrafficSourceWidget
|
|
{...props}
|
|
emptyMessage="No search traffic in this period"
|
|
title="Search engines"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AiEngines(props: {
|
|
engines: Array<{ name: string; sessions: number }>;
|
|
total: number;
|
|
previousTotal: number;
|
|
isLoading: boolean;
|
|
}) {
|
|
return (
|
|
<TrafficSourceWidget
|
|
{...props}
|
|
emptyMessage="No AI traffic in this period"
|
|
title="AI referrals"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function GscChart({
|
|
data,
|
|
isLoading,
|
|
}: {
|
|
data: Array<{ date: string; clicks: number; impressions: number }>;
|
|
isLoading: boolean;
|
|
}) {
|
|
const color = getChartColor(0);
|
|
const yAxisProps = useYAxisProps();
|
|
|
|
return (
|
|
<div className="card p-4">
|
|
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
|
{isLoading ? (
|
|
<Skeleton className="h-48 w-full" />
|
|
) : (
|
|
<TooltipProvider>
|
|
<ResponsiveContainer height={200} width="100%">
|
|
<ComposedChart data={data}>
|
|
<defs>
|
|
<filter
|
|
height="140%"
|
|
id="gsc-line-glow"
|
|
width="140%"
|
|
x="-20%"
|
|
y="-20%"
|
|
>
|
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
|
<feFuncA slope="0.5" type="linear" />
|
|
</feComponentTransfer>
|
|
<feComposite
|
|
in="SourceGraphic"
|
|
in2="dimmedBlur"
|
|
operator="over"
|
|
/>
|
|
</filter>
|
|
</defs>
|
|
<CartesianGrid
|
|
className="stroke-border"
|
|
horizontal
|
|
strokeDasharray="3 3"
|
|
vertical={false}
|
|
/>
|
|
<XAxis
|
|
{...X_AXIS_STYLE_PROPS}
|
|
dataKey="date"
|
|
tickFormatter={(v: string) => v.slice(5)}
|
|
type="category"
|
|
/>
|
|
<YAxis {...yAxisProps} yAxisId="left" />
|
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
|
<GscTooltip />
|
|
<Line
|
|
dataKey="clicks"
|
|
dot={false}
|
|
filter="url(#gsc-line-glow)"
|
|
isAnimationActive={false}
|
|
stroke={color}
|
|
strokeWidth={2}
|
|
type="monotone"
|
|
yAxisId="left"
|
|
/>
|
|
<Line
|
|
dataKey="impressions"
|
|
dot={false}
|
|
filter="url(#gsc-line-glow)"
|
|
isAnimationActive={false}
|
|
stroke={getChartColor(1)}
|
|
strokeWidth={2}
|
|
type="monotone"
|
|
yAxisId="right"
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="card overflow-hidden">
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<h3 className="font-medium text-sm">{title}</h3>
|
|
</div>
|
|
<OverviewWidgetTable
|
|
columns={[
|
|
{
|
|
name: keyLabel,
|
|
width: 'w-full',
|
|
render: () => <Skeleton className="h-4 w-2/3" />,
|
|
},
|
|
{
|
|
name: 'Clicks',
|
|
width: '70px',
|
|
render: () => <Skeleton className="h-4 w-10" />,
|
|
},
|
|
{
|
|
name: 'Impr.',
|
|
width: '70px',
|
|
render: () => <Skeleton className="h-4 w-10" />,
|
|
},
|
|
{
|
|
name: 'CTR',
|
|
width: '60px',
|
|
render: () => <Skeleton className="h-4 w-8" />,
|
|
},
|
|
{
|
|
name: 'Pos.',
|
|
width: '55px',
|
|
render: () => <Skeleton className="h-4 w-8" />,
|
|
},
|
|
]}
|
|
data={[1, 2, 3, 4, 5]}
|
|
getColumnPercentage={() => 0}
|
|
keyExtractor={(i) => String(i)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="card">
|
|
<div className="border-b">
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<h3 className="font-medium text-sm">{title}</h3>
|
|
{showPagination && (
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
|
{totalCount === 0
|
|
? '0 results'
|
|
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
|
|
</span>
|
|
<Pagination
|
|
canNextPage={canNextPage}
|
|
canPreviousPage={canPreviousPage}
|
|
nextPage={onNextPage}
|
|
pageIndex={pageIndex}
|
|
previousPage={onPreviousPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{onSearchChange != null && (
|
|
<div className="relative">
|
|
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
className="rounded-none border-0 border-t bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
placeholder={searchPlaceholder ?? 'Search'}
|
|
type="search"
|
|
value={searchValue ?? ''}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<OverviewWidgetTable
|
|
columns={[
|
|
{
|
|
name: keyLabel,
|
|
width: 'w-full',
|
|
render(item) {
|
|
return (
|
|
<div className="min-w-0 overflow-hidden">
|
|
<button
|
|
className="block w-full truncate text-left font-mono text-xs hover:underline"
|
|
onClick={() => onRowClick?.(String(item[keyField]))}
|
|
type="button"
|
|
>
|
|
{String(item[keyField])}
|
|
</button>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'Clicks',
|
|
width: '70px',
|
|
getSortValue: (item) => item.clicks,
|
|
render(item) {
|
|
return (
|
|
<span className="font-mono font-semibold text-xs">
|
|
{item.clicks.toLocaleString()}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'Impr.',
|
|
width: '70px',
|
|
getSortValue: (item) => item.impressions,
|
|
render(item) {
|
|
return (
|
|
<span className="font-mono font-semibold text-xs">
|
|
{item.impressions.toLocaleString()}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'CTR',
|
|
width: '60px',
|
|
getSortValue: (item) => item.ctr,
|
|
render(item) {
|
|
return (
|
|
<span className="font-mono font-semibold text-xs">
|
|
{(item.ctr * 100).toFixed(1)}%
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'Pos.',
|
|
width: '55px',
|
|
getSortValue: (item) => item.position,
|
|
render(item) {
|
|
return (
|
|
<span className="font-mono font-semibold text-xs">
|
|
{item.position.toFixed(1)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
]}
|
|
data={rows}
|
|
getColumnPercentage={(item) => item.clicks / maxClicks}
|
|
keyExtractor={(item) => String(item[keyField])}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|