feat: added google search console
This commit is contained in:
committed by
GitHub
parent
70ca44f039
commit
271d189ed0
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface GscBreakdownTableProps {
|
||||
projectId: string;
|
||||
value: string;
|
||||
type: 'page' | 'query';
|
||||
}
|
||||
|
||||
export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
};
|
||||
|
||||
const pageQuery = useQuery(
|
||||
trpc.gsc.getPageDetails.queryOptions(
|
||||
{ projectId, page: value, ...dateInput },
|
||||
{ enabled: type === 'page' },
|
||||
),
|
||||
);
|
||||
|
||||
const queryQuery = useQuery(
|
||||
trpc.gsc.getQueryDetails.queryOptions(
|
||||
{ projectId, query: value, ...dateInput },
|
||||
{ enabled: type === 'query' },
|
||||
),
|
||||
);
|
||||
|
||||
const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
|
||||
const breakdownRows: Record<string, string | number>[] =
|
||||
type === 'page'
|
||||
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
|
||||
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
|
||||
|
||||
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||
const pluralLabel = type === 'page' ? 'queries' : 'pages';
|
||||
|
||||
const maxClicks = Math.max(
|
||||
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b p-4">
|
||||
<h3 className="font-medium text-sm">Top {pluralLabel}</h3>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<OverviewWidgetTable
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
keyExtractor={(i) => String(i)}
|
||||
getColumnPercentage={() => 0}
|
||||
columns={[
|
||||
{ name: breakdownLabel, 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" /> },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<OverviewWidgetTable
|
||||
data={breakdownRows}
|
||||
keyExtractor={(item) => String(item[breakdownKey])}
|
||||
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
|
||||
columns={[
|
||||
{
|
||||
name: breakdownLabel,
|
||||
width: 'w-full',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<span className="block truncate font-mono text-xs">
|
||||
{String(item[breakdownKey])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.clicks as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.clicks as number).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.impressions as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.impressions as number).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
getSortValue: (item) => item.ctr as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{((item.ctr as number) * 100).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
getSortValue: (item) => item.position as number,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.position as number).toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
226
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface GscCannibalizationProps {
|
||||
projectId: string;
|
||||
range: IChartRange;
|
||||
interval: IInterval;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function GscCannibalization({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
}: GscCannibalizationProps) {
|
||||
const trpc = useTRPC();
|
||||
const { apiUrl } = useAppContext();
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 15;
|
||||
|
||||
const query = useQuery(
|
||||
trpc.gsc.getCannibalization.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
);
|
||||
|
||||
const toggle = (q: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(q)) {
|
||||
next.delete(q);
|
||||
} else {
|
||||
next.add(q);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const items = query.data ?? [];
|
||||
|
||||
const pageCount = Math.ceil(items.length / pageSize) || 1;
|
||||
useEffect(() => {
|
||||
setPage((p) => Math.max(0, Math.min(p, pageCount - 1)));
|
||||
}, [items, pageSize, pageCount]);
|
||||
const paginatedItems = useMemo(
|
||||
() => items.slice(page * pageSize, (page + 1) * pageSize),
|
||||
[items, page, pageSize]
|
||||
);
|
||||
const rangeStart = items.length ? page * pageSize + 1 : 0;
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
|
||||
|
||||
if (!(query.isLoading || items.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
||||
{items.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{items.length === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={page < pageCount - 1}
|
||||
canPreviousPage={page > 0}
|
||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
pageIndex={page}
|
||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{query.isLoading &&
|
||||
[1, 2, 3].map((i) => (
|
||||
<div className="space-y-2 p-4" key={i}>
|
||||
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
{paginatedItems.map((item) => {
|
||||
const isOpen = expanded.has(item.query);
|
||||
const avgCtr =
|
||||
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
|
||||
|
||||
return (
|
||||
<div key={item.query}>
|
||||
<button
|
||||
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/40"
|
||||
onClick={() => toggle(item.query)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'row shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs',
|
||||
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
)}
|
||||
>
|
||||
<AlertCircleIcon className="size-3" />
|
||||
{item.pages.length} pages
|
||||
</div>
|
||||
<span className="truncate font-medium text-sm">
|
||||
{item.query}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<span className="whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||
{item.totalImpressions.toLocaleString()} impr ·{' '}
|
||||
{(avgCtr * 100).toFixed(1)}% avg CTR
|
||||
</span>
|
||||
<ChevronsUpDownIcon
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t bg-muted/20 px-4 py-3">
|
||||
<p className="mb-3 text-muted-foreground text-xs leading-normal">
|
||||
These pages all rank for{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
"{item.query}"
|
||||
</span>
|
||||
. Consider consolidating weaker pages into the top-ranking
|
||||
one to concentrate link equity and avoid splitting clicks.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{item.pages.map((page, idx) => {
|
||||
// Strip hash fragments — GSC sometimes returns heading
|
||||
// anchor URLs (e.g. /page#section) as separate entries
|
||||
let cleanUrl = page.page;
|
||||
let origin = '';
|
||||
let path = page.page;
|
||||
try {
|
||||
const u = new URL(page.page);
|
||||
u.hash = '';
|
||||
cleanUrl = u.toString();
|
||||
origin = u.origin;
|
||||
path = u.pathname + u.search;
|
||||
} catch {
|
||||
cleanUrl = page.page.split('#')[0] ?? page.page;
|
||||
}
|
||||
const isWinner = idx === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
|
||||
key={page.page}
|
||||
onClick={() =>
|
||||
pushModal('PageDetails', {
|
||||
type: 'page',
|
||||
projectId,
|
||||
value: cleanUrl,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="size-3.5 shrink-0 rounded-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display =
|
||||
'none';
|
||||
}}
|
||||
src={`${apiUrl}/misc/favicon?url=${origin}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{path || page.page}
|
||||
</span>
|
||||
{isWinner && (
|
||||
<span className="shrink-0 rounded bg-emerald-100 px-1 py-0.5 font-medium text-emerald-700 text-xs dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||
#1
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||
pos {page.position.toFixed(1)} ·{' '}
|
||||
{(page.ctr * 100).toFixed(1)}% CTR ·{' '}
|
||||
{page.impressions.toLocaleString()} impr
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ChartData {
|
||||
date: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
{ formatDate: (date: Date | string) => string }
|
||||
>(({ data, context }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{context.formatDate(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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface GscClicksChartProps {
|
||||
projectId: string;
|
||||
value: string;
|
||||
type: 'page' | 'query';
|
||||
}
|
||||
|
||||
export function GscClicksChart({
|
||||
projectId,
|
||||
value,
|
||||
type,
|
||||
}: GscClicksChartProps) {
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
const yAxisProps = useYAxisProps();
|
||||
const formatDateShort = useFormatDateInterval({ interval, short: true });
|
||||
const formatDateLong = useFormatDateInterval({ interval, short: false });
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
};
|
||||
|
||||
const pageQuery = useQuery(
|
||||
trpc.gsc.getPageDetails.queryOptions(
|
||||
{ projectId, page: value, ...dateInput },
|
||||
{ enabled: type === 'page' }
|
||||
)
|
||||
);
|
||||
|
||||
const queryQuery = useQuery(
|
||||
trpc.gsc.getQueryDetails.queryOptions(
|
||||
{ projectId, query: value, ...dateInput },
|
||||
{ enabled: type === 'query' }
|
||||
)
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||
const timeseries =
|
||||
(type === 'page'
|
||||
? pageQuery.data?.timeseries
|
||||
: queryQuery.data?.timeseries) ?? [];
|
||||
|
||||
const data: ChartData[] = timeseries.map((r) => ({
|
||||
date: r.date,
|
||||
clicks: r.clicks,
|
||||
impressions: r.impressions,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">Clicks & Impressions</h3>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(0) }}
|
||||
/>
|
||||
Clicks
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(1) }}
|
||||
/>
|
||||
Impressions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider formatDate={formatDateLong}>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-clicks-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) => formatDateShort(v)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
dataKey="clicks"
|
||||
dot={false}
|
||||
filter="url(#gsc-clicks-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="impressions"
|
||||
dot={false}
|
||||
filter="url(#gsc-clicks-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
// Industry average CTR by position (Google organic)
|
||||
const BENCHMARK: Record<number, number> = {
|
||||
1: 28.5,
|
||||
2: 15.7,
|
||||
3: 11.0,
|
||||
4: 8.0,
|
||||
5: 6.3,
|
||||
6: 5.0,
|
||||
7: 4.0,
|
||||
8: 3.3,
|
||||
9: 2.8,
|
||||
10: 2.5,
|
||||
11: 2.2,
|
||||
12: 2.0,
|
||||
13: 1.8,
|
||||
14: 1.5,
|
||||
15: 1.2,
|
||||
16: 1.1,
|
||||
17: 1.0,
|
||||
18: 0.9,
|
||||
19: 0.8,
|
||||
20: 0.7,
|
||||
};
|
||||
|
||||
interface PageEntry {
|
||||
path: string;
|
||||
ctr: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
position: number;
|
||||
yourCtr: number | null;
|
||||
benchmark: number;
|
||||
pages: PageEntry[];
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>Position #{item.position}</div>
|
||||
</ChartTooltipHeader>
|
||||
{item.yourCtr != null && (
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Your avg CTR</span>
|
||||
<span>{item.yourCtr.toFixed(1)}%</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
<ChartTooltipItem color={getChartColor(3)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Benchmark</span>
|
||||
<span>{item.benchmark.toFixed(1)}%</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
{item.pages.length > 0 && (
|
||||
<div className="mt-1.5 border-t pt-1.5">
|
||||
{item.pages.map((p) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 py-0.5"
|
||||
key={p.path}
|
||||
>
|
||||
<span className="max-w-40 truncate font-mono text-muted-foreground text-xs">
|
||||
{p.path}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums">
|
||||
{(p.ctr * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface GscCtrBenchmarkProps {
|
||||
data: Array<{
|
||||
page: string;
|
||||
position: number;
|
||||
ctr: number;
|
||||
impressions: number;
|
||||
}>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GscCtrBenchmark({ data, isLoading }: GscCtrBenchmarkProps) {
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
const grouped = new Map<number, { ctrSum: number; pages: PageEntry[] }>();
|
||||
for (const d of data) {
|
||||
const pos = Math.round(d.position);
|
||||
if (pos < 1 || pos > 20 || d.impressions < 10) {
|
||||
continue;
|
||||
}
|
||||
let path = d.page;
|
||||
try {
|
||||
path = new URL(d.page).pathname;
|
||||
} catch {
|
||||
// keep as-is
|
||||
}
|
||||
const entry = grouped.get(pos) ?? { ctrSum: 0, pages: [] };
|
||||
entry.ctrSum += d.ctr * 100;
|
||||
entry.pages.push({ path, ctr: d.ctr, impressions: d.impressions });
|
||||
grouped.set(pos, entry);
|
||||
}
|
||||
|
||||
const chartData: ChartData[] = Array.from({ length: 20 }, (_, i) => {
|
||||
const pos = i + 1;
|
||||
const entry = grouped.get(pos);
|
||||
const pages = entry
|
||||
? [...entry.pages].sort((a, b) => b.ctr - a.ctr).slice(0, 5)
|
||||
: [];
|
||||
return {
|
||||
position: pos,
|
||||
yourCtr: entry ? entry.ctrSum / entry.pages.length : null,
|
||||
benchmark: BENCHMARK[pos] ?? 0,
|
||||
pages,
|
||||
};
|
||||
});
|
||||
|
||||
const hasAnyData = chartData.some((d) => d.yourCtr != null);
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">CTR vs Position</h3>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
{hasAnyData && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(0) }}
|
||||
/>
|
||||
Your CTR
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full opacity-60"
|
||||
style={{ backgroundColor: getChartColor(3) }}
|
||||
/>
|
||||
Benchmark
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="position"
|
||||
domain={[1, 20]}
|
||||
tickFormatter={(v: number) => `#${v}`}
|
||||
ticks={[1, 5, 10, 15, 20]}
|
||||
type="number"
|
||||
/>
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, 'auto']}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
dataKey="yourCtr"
|
||||
dot={{ r: 3, fill: getChartColor(0) }}
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="benchmark"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(3)}
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={1.5}
|
||||
type="monotone"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ChartData {
|
||||
date: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{item.date}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(2)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Avg Position</span>
|
||||
<span>{item.position.toFixed(1)}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface GscPositionChartProps {
|
||||
data: Array<{ date: string; position: number }>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GscPositionChart({ data, isLoading }: GscPositionChartProps) {
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
const chartData: ChartData[] = data.map((r) => ({
|
||||
date: r.date,
|
||||
position: r.position,
|
||||
}));
|
||||
|
||||
const positions = chartData.map((d) => d.position).filter((p) => p > 0);
|
||||
const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1;
|
||||
const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20;
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">Avg Position</h3>
|
||||
<span className="text-muted-foreground text-xs">Lower is better</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-pos-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}
|
||||
domain={[minPos, maxPos]}
|
||||
reversed
|
||||
tickFormatter={(v: number) => `#${v}`}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
dataKey="position"
|
||||
dot={false}
|
||||
filter="url(#gsc-pos-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(2)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface ChartData {
|
||||
date: string;
|
||||
pageviews: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||
ChartData,
|
||||
{ formatDate: (date: Date | string) => string }
|
||||
>(({ data, context }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{context.formatDate(item.date)}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Views</span>
|
||||
<span>{item.pageviews.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
<ChartTooltipItem color={getChartColor(1)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Sessions</span>
|
||||
<span>{item.sessions.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface PageViewsChartProps {
|
||||
projectId: string;
|
||||
origin: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function PageViewsChart({
|
||||
projectId,
|
||||
origin,
|
||||
path,
|
||||
}: PageViewsChartProps) {
|
||||
const { range, interval } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
const yAxisProps = useYAxisProps();
|
||||
const formatDateShort = useFormatDateInterval({ interval, short: true });
|
||||
const formatDateLong = useFormatDateInterval({ interval, short: false });
|
||||
|
||||
const query = useQuery(
|
||||
trpc.event.pageTimeseries.queryOptions({
|
||||
projectId,
|
||||
range,
|
||||
interval,
|
||||
origin,
|
||||
path,
|
||||
})
|
||||
);
|
||||
|
||||
const data: ChartData[] = (query.data ?? []).map((r) => ({
|
||||
date: r.date,
|
||||
pageviews: r.pageviews,
|
||||
sessions: r.sessions,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">Views & Sessions</h3>
|
||||
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(0) }}
|
||||
/>
|
||||
Views
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(1) }}
|
||||
/>
|
||||
Sessions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{query.isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<TooltipProvider formatDate={formatDateLong}>
|
||||
<ResponsiveContainer height={160} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="page-views-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) => formatDateShort(v)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
dataKey="pageviews"
|
||||
dot={false}
|
||||
filter="url(#page-views-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="sessions"
|
||||
dot={false}
|
||||
filter="url(#page-views-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
apps/start/src/components/page/pages-insights.tsx
Normal file
332
apps/start/src/components/page/pages-insights.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
EyeIcon,
|
||||
MousePointerClickIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
type InsightType =
|
||||
| 'low_ctr'
|
||||
| 'near_page_one'
|
||||
| 'invisible_clicks'
|
||||
| 'high_bounce';
|
||||
|
||||
interface PageInsight {
|
||||
page: string;
|
||||
origin: string;
|
||||
path: string;
|
||||
type: InsightType;
|
||||
impact: number;
|
||||
headline: string;
|
||||
suggestion: string;
|
||||
metrics: string;
|
||||
}
|
||||
|
||||
const INSIGHT_CONFIG: Record<
|
||||
InsightType,
|
||||
{ label: string; icon: React.ElementType; color: string; bg: string }
|
||||
> = {
|
||||
low_ctr: {
|
||||
label: 'Low CTR',
|
||||
icon: MousePointerClickIcon,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
},
|
||||
near_page_one: {
|
||||
label: 'Near page 1',
|
||||
icon: TrendingUpIcon,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
},
|
||||
invisible_clicks: {
|
||||
label: 'Low visibility',
|
||||
icon: EyeIcon,
|
||||
color: 'text-violet-600 dark:text-violet-400',
|
||||
bg: 'bg-violet-100 dark:bg-violet-900/30',
|
||||
},
|
||||
high_bounce: {
|
||||
label: 'High bounce',
|
||||
icon: AlertTriangleIcon,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||
},
|
||||
};
|
||||
|
||||
interface PagesInsightsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||
const trpc = useTRPC();
|
||||
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||
const { apiUrl } = useAppContext();
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 8;
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
startDate: startDate ?? undefined,
|
||||
endDate: endDate ?? undefined,
|
||||
};
|
||||
|
||||
const gscPagesQuery = useQuery(
|
||||
trpc.gsc.getPages.queryOptions(
|
||||
{ projectId, ...dateInput, limit: 1000 },
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
);
|
||||
|
||||
const analyticsQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval },
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
);
|
||||
|
||||
const insights = useMemo<PageInsight[]>(() => {
|
||||
const gscPages = gscPagesQuery.data ?? [];
|
||||
const analyticsPages = analyticsQuery.data ?? [];
|
||||
|
||||
const analyticsMap = new Map(
|
||||
analyticsPages.map((p) => [p.origin + p.path, p])
|
||||
);
|
||||
|
||||
const results: PageInsight[] = [];
|
||||
|
||||
for (const gsc of gscPages) {
|
||||
let origin = '';
|
||||
let path = gsc.page;
|
||||
try {
|
||||
const url = new URL(gsc.page);
|
||||
origin = url.origin;
|
||||
path = url.pathname + url.search;
|
||||
} catch {
|
||||
// keep as-is
|
||||
}
|
||||
|
||||
const analytics = analyticsMap.get(gsc.page);
|
||||
|
||||
// 1. Low CTR: ranking on page 1 but click rate is poor
|
||||
if (gsc.position <= 10 && gsc.ctr < 0.04 && gsc.impressions >= 100) {
|
||||
results.push({
|
||||
page: gsc.page,
|
||||
origin,
|
||||
path,
|
||||
type: 'low_ctr',
|
||||
impact: gsc.impressions * (0.04 - gsc.ctr),
|
||||
headline: `Ranking #${Math.round(gsc.position)} but only ${(gsc.ctr * 100).toFixed(1)}% CTR`,
|
||||
suggestion:
|
||||
'You are on page 1 but people rarely click. Rewrite your title tag and meta description to be more compelling and match search intent.',
|
||||
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${(gsc.ctr * 100).toFixed(1)}% CTR`,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Near page 1: just off the first page with decent visibility
|
||||
if (gsc.position > 10 && gsc.position <= 20 && gsc.impressions >= 100) {
|
||||
results.push({
|
||||
page: gsc.page,
|
||||
origin,
|
||||
path,
|
||||
type: 'near_page_one',
|
||||
impact: gsc.impressions / gsc.position,
|
||||
headline: `Position ${Math.round(gsc.position)} — one push from page 1`,
|
||||
suggestion:
|
||||
'A content refresh, more internal links, or a few backlinks could move this into the top 10 and dramatically increase clicks.',
|
||||
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks`,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Invisible clicks: high impressions but barely any clicks
|
||||
if (gsc.impressions >= 500 && gsc.ctr < 0.01 && gsc.position > 10) {
|
||||
results.push({
|
||||
page: gsc.page,
|
||||
origin,
|
||||
path,
|
||||
type: 'invisible_clicks',
|
||||
impact: gsc.impressions,
|
||||
headline: `${gsc.impressions.toLocaleString()} impressions but only ${gsc.clicks} clicks`,
|
||||
suggestion:
|
||||
'Google shows this page a lot, but it almost never gets clicked. Consider whether the page targets the right queries or if a different format (e.g. listicle, how-to) would perform better.',
|
||||
metrics: `${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks · Pos ${Math.round(gsc.position)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. High bounce: good traffic but poor engagement (requires analytics match)
|
||||
if (
|
||||
analytics &&
|
||||
analytics.bounce_rate >= 70 &&
|
||||
analytics.sessions >= 20
|
||||
) {
|
||||
results.push({
|
||||
page: gsc.page,
|
||||
origin,
|
||||
path,
|
||||
type: 'high_bounce',
|
||||
impact: analytics.sessions * (analytics.bounce_rate / 100),
|
||||
headline: `${Math.round(analytics.bounce_rate)}% bounce rate on a page with ${analytics.sessions} sessions`,
|
||||
suggestion:
|
||||
'Visitors are leaving without engaging. Check if the page delivers on its title/meta promise, improve page speed, and make sure key content is above the fold.',
|
||||
metrics: `${Math.round(analytics.bounce_rate)}% bounce · ${analytics.sessions} sessions · ${gsc.impressions.toLocaleString()} impr`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also check analytics pages without GSC match for high bounce
|
||||
for (const p of analyticsPages) {
|
||||
const fullUrl = p.origin + p.path;
|
||||
if (
|
||||
!gscPagesQuery.data?.some((g) => g.page === fullUrl) &&
|
||||
p.bounce_rate >= 75 &&
|
||||
p.sessions >= 30
|
||||
) {
|
||||
results.push({
|
||||
page: fullUrl,
|
||||
origin: p.origin,
|
||||
path: p.path,
|
||||
type: 'high_bounce',
|
||||
impact: p.sessions * (p.bounce_rate / 100),
|
||||
headline: `${Math.round(p.bounce_rate)}% bounce rate with ${p.sessions} sessions`,
|
||||
suggestion:
|
||||
'High bounce rate with no search visibility. Review content quality and check if the page is indexed and targeting the right keywords.',
|
||||
metrics: `${Math.round(p.bounce_rate)}% bounce · ${p.sessions} sessions`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe by (page, type), keep highest impact
|
||||
const seen = new Set<string>();
|
||||
const deduped = results.filter((r) => {
|
||||
const key = `${r.page}::${r.type}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return deduped.sort((a, b) => b.impact - a.impact);
|
||||
}, [gscPagesQuery.data, analyticsQuery.data]);
|
||||
|
||||
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
|
||||
|
||||
const pageCount = Math.ceil(insights.length / pageSize) || 1;
|
||||
const paginatedInsights = useMemo(
|
||||
() => insights.slice(page * pageSize, (page + 1) * pageSize),
|
||||
[insights, page, pageSize]
|
||||
);
|
||||
const rangeStart = insights.length ? page * pageSize + 1 : 0;
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, insights.length);
|
||||
|
||||
if (!isLoading && !insights.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">Opportunities</h3>
|
||||
{insights.length > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||
{insights.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{insights.length > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{insights.length === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${insights.length}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={page < pageCount - 1}
|
||||
canPreviousPage={page > 0}
|
||||
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
pageIndex={page}
|
||||
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{isLoading &&
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div className="flex items-start gap-3 p-4" key={i}>
|
||||
<div className="mt-0.5 h-7 w-20 animate-pulse rounded-md bg-muted" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
{paginatedInsights.map((insight, i) => {
|
||||
const config = INSIGHT_CONFIG[insight.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-start gap-3 p-4 text-left transition-colors hover:bg-muted/40"
|
||||
key={`${insight.page}-${insight.type}-${i}`}
|
||||
onClick={() =>
|
||||
pushModal('PageDetails', {
|
||||
type: 'page',
|
||||
projectId,
|
||||
value: insight.page,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="col min-w-0 flex-1 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt=""
|
||||
className="size-3.5 shrink-0 rounded-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={`${apiUrl}/misc/favicon?url=${insight.origin}`}
|
||||
/>
|
||||
<span className="truncate font-medium font-mono text-xs">
|
||||
{insight.path || insight.page}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'row shrink-0 items-center gap-1 rounded-md px-1 py-0.5 font-medium text-xs',
|
||||
config.color,
|
||||
config.bg
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
<span className="font-medium text-foreground">
|
||||
{insight.headline}.
|
||||
</span>{' '}
|
||||
{insight.suggestion}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||
{insight.metrics}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user