feat: added google search console

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-09 20:47:02 +01:00
committed by GitHub
parent 70ca44f039
commit 271d189ed0
51 changed files with 5471 additions and 503 deletions

View File

@@ -1,24 +1,27 @@
import { pushModal } from '@/modals';
import type {
IReport,
IChartRange,
IChartType,
IInterval,
IReport,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { ReportChart } from '../report-chart';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
import { pushModal } from '@/modals';
export function ChatReport({
lazy,
...props
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
}: {
report: IReport & { startDate: string; endDate: string };
lazy: boolean;
}) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
props.report.chartType
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
@@ -35,47 +38,48 @@ export function ChatReport({
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
<div className="pt-4 text-center font-medium font-mono text-sm">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="row justify-between gap-1 border-border border-t p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate}
onChange={setRange}
onEndDateChange={setEndDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={report.startDate}
value={report.range}
/>
<ReportInterval
chartType={chartType}
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
range={range}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
value={chartType}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
size="sm"
variant="outline"
>
Save report
</Button>

View File

@@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker';
export function OverviewRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
useOverviewOptions();
const {
range,
setRange,
setStartDate,
setEndDate,
endDate,
startDate,
setInterval,
} = useOverviewOptions();
return (
<TimeWindowPicker
onChange={setRange}
value={range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={endDate}
onChange={setRange}
onEndDateChange={setEndDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={startDate}
value={range}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query';
import { Tooltiper } from '../ui/tooltip';
import { LazyComponent } from '@/components/lazy-component';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useTRPC } from '@/integrations/trpc/react';
interface SparklineBarsProps {
data: { date: string; pageviews: number }[];
}
const defaultGap = 1;
const height = 24;
const width = 100;
function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' {
const n = data.length;
if (n < 3) {
return '→';
}
const third = Math.max(1, Math.floor(n / 3));
const firstAvg =
data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third;
const lastAvg =
data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third;
const threshold = firstAvg * 0.05;
if (lastAvg - firstAvg > threshold) {
return '↑';
}
if (firstAvg - lastAvg > threshold) {
return '↓';
}
return '→';
}
function SparklineBars({ data }: SparklineBarsProps) {
if (!data.length) {
return <div style={{ height, width }} />;
}
const max = Math.max(...data.map((d) => d.pageviews), 1);
const total = data.length;
// Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible
let gap = defaultGap;
let barW = Math.floor((width - gap * (total - 1)) / total);
if (barW < 1 && total > 1) {
gap = 0;
barW = Math.floor((width - gap * (total - 1)) / total);
}
if (barW < 1) {
barW = 1;
}
const trend = getTrendDirection(data);
const trendColor =
trend === '↑'
? 'text-emerald-500'
: trend === '↓'
? 'text-red-500'
: 'text-muted-foreground';
return (
<div className="flex items-center gap-1.5">
<svg className="shrink-0" height={height} width={width}>
{data.map((d, i) => {
const barH = Math.max(
2,
Math.round((d.pageviews / max) * (height * 0.8))
);
return (
<rect
className="fill-chart-0"
height={barH}
key={d.date}
rx="1"
width={barW}
x={i * (barW + gap)}
y={height - barH}
/>
);
})}
</svg>
<Tooltiper
content={
trend === '↑'
? 'Upward trend'
: trend === '↓'
? 'Downward trend'
: 'Stable trend'
}
>
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
{trend}
</span>
</Tooltiper>
</div>
);
}
interface PageSparklineProps {
projectId: string;
origin: string;
path: string;
}
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
const { range, interval } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.event.pageTimeseries.queryOptions({
projectId,
range,
interval,
origin,
path,
})
);
return (
<LazyComponent fallback={<div style={{ height, width }} />}>
<SparklineBars data={query.data ?? []} />
</LazyComponent>
);
}

View File

@@ -0,0 +1,206 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ExternalLinkIcon } from 'lucide-react';
import { useMemo } from 'react';
import { PageSparkline } from '@/components/pages/page-sparkline';
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
import { useAppContext } from '@/hooks/use-app-context';
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
export type PageRow = RouterOutputs['event']['pages'][number] & {
gsc?: { clicks: number; impressions: number; ctr: number; position: number };
};
export function useColumns({
projectId,
isGscConnected,
previousMap,
}: {
projectId: string;
isGscConnected: boolean;
previousMap?: Map<string, number>;
}): ColumnDef<PageRow>[] {
const number = useNumber();
const { apiUrl } = useAppContext();
return useMemo<ColumnDef<PageRow>[]>(() => {
const cols: ColumnDef<PageRow>[] = [
{
id: 'page',
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
header: createHeaderColumn('Page'),
size: 400,
meta: { bold: true },
cell: ({ row }) => {
const page = row.original;
return (
<div className="flex min-w-0 items-center gap-3">
<img
alt=""
className="size-4 shrink-0 rounded-sm"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
/>
<div className="min-w-0">
{page.title && (
<div className="truncate font-medium text-sm leading-tight">
{page.title}
</div>
)}
<div className="flex min-w-0 items-center gap-1">
<span className="truncate font-mono text-muted-foreground text-xs">
{page.path}
</span>
<a
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
href={page.origin + page.path}
onClick={(e) => e.stopPropagation()}
rel="noreferrer noopener"
target="_blank"
>
<ExternalLinkIcon className="size-3 text-muted-foreground" />
</a>
</div>
</div>
</div>
);
},
},
{
id: 'trend',
header: 'Trend',
enableSorting: false,
size: 96,
cell: ({ row }) => (
<PageSparkline
origin={row.original.origin}
path={row.original.path}
projectId={projectId}
/>
),
},
{
accessorKey: 'pageviews',
header: createHeaderColumn('Views'),
size: 80,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.pageviews)}
</span>
),
},
{
accessorKey: 'sessions',
header: createHeaderColumn('Sessions'),
size: 90,
cell: ({ row }) => {
const prev = previousMap?.get(
row.original.origin + row.original.path
);
if (prev == null) {
return <span className="text-muted-foreground"></span>;
}
if (prev === 0) {
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span className="text-muted-foreground">new</span>
</div>
);
}
const pct = ((row.original.sessions - prev) / prev) * 100;
const isPos = pct >= 0;
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
>
{isPos ? '+' : ''}
{pct.toFixed(1)}%
</span>
</div>
);
},
},
{
accessorKey: 'bounce_rate',
header: createHeaderColumn('Bounce'),
size: 80,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{row.original.bounce_rate.toFixed(0)}%
</span>
),
},
{
accessorKey: 'avg_duration',
header: createHeaderColumn('Duration'),
size: 90,
cell: ({ row }) => (
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
{fancyMinutes(row.original.avg_duration)}
</span>
),
},
];
if (isGscConnected) {
cols.push(
{
id: 'gsc_impressions',
accessorFn: (row) => row.gsc?.impressions ?? 0,
header: createHeaderColumn('Impr.'),
size: 80,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.gsc.impressions)}
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'gsc_ctr',
accessorFn: (row) => row.gsc?.ctr ?? 0,
header: createHeaderColumn('CTR'),
size: 70,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{(row.original.gsc.ctr * 100).toFixed(1)}%
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'gsc_clicks',
accessorFn: (row) => row.gsc?.clicks ?? 0,
header: createHeaderColumn('Clicks'),
size: 80,
cell: ({ row }) =>
row.original.gsc ? (
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.gsc.clicks)}
</span>
) : (
<span className="text-muted-foreground"></span>
),
}
);
}
return cols;
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
}

View File

@@ -0,0 +1,143 @@
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { DataTable } from '@/components/ui/data-table/data-table';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useTable } from '@/components/ui/data-table/use-table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { type PageRow, useColumns } from './columns';
interface PagesTableProps {
projectId: string;
}
export function PagesTable({ projectId }: PagesTableProps) {
const trpc = useTRPC();
const { range, interval, startDate, endDate } = useOverviewOptions();
const { debouncedSearch, setSearch, search } = useSearchQueryState();
const pagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
search: debouncedSearch ?? undefined,
range,
interval,
},
{ placeholderData: keepPreviousData },
),
);
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId }),
);
const isGscConnected = !!(connectionQuery.data?.siteUrl);
const gscPagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{
projectId,
range,
startDate: startDate ?? undefined,
endDate: endDate ?? undefined,
limit: 10_000,
},
{ enabled: isGscConnected },
),
);
const previousPagesQuery = useQuery(
trpc.event.previousPages.queryOptions(
{ projectId, range, interval },
{ placeholderData: keepPreviousData },
),
);
const previousMap = useMemo(() => {
const map = new Map<string, number>();
for (const p of previousPagesQuery.data ?? []) {
map.set(p.origin + p.path, p.sessions);
}
return map;
}, [previousPagesQuery.data]);
const gscMap = useMemo(() => {
const map = new Map<
string,
{ clicks: number; impressions: number; ctr: number; position: number }
>();
for (const row of gscPagesQuery.data ?? []) {
map.set(row.page, {
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
});
}
return map;
}, [gscPagesQuery.data]);
const rawData: PageRow[] = useMemo(() => {
return (pagesQuery.data ?? []).map((p) => ({
...p,
gsc: gscMap.get(p.origin + p.path),
}));
}, [pagesQuery.data, gscMap]);
const columns = useColumns({ projectId, isGscConnected, previousMap });
const { table } = useTable({
columns,
data: rawData,
loading: pagesQuery.isLoading,
pageSize: 50,
name: 'pages',
});
return (
<>
<DataTableToolbarContainer>
<AnimatedSearchInput
placeholder="Search pages"
value={search ?? ''}
onChange={setSearch}
/>
<div className="flex items-center gap-2">
<OverviewRange />
<OverviewInterval />
<DataTableViewOptions table={table} />
</div>
</DataTableToolbarContainer>
<DataTable
table={table}
loading={pagesQuery.isLoading}
empty={{
title: 'No pages',
description: debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web SDK to your site to get pages here.',
}}
onRowClick={(row) => {
if (!isGscConnected) {
return;
}
const page = row.original;
pushModal('PageDetails', {
type: 'page',
projectId,
value: page.origin + page.path,
});
}}
/>
</>
);
}

View File

@@ -144,6 +144,7 @@ const data = {
"dropbox": "https://www.dropbox.com",
"openai": "https://openai.com",
"chatgpt.com": "https://chatgpt.com",
"copilot.com": "https://www.copilot.com",
"mailchimp": "https://mailchimp.com",
"activecampaign": "https://www.activecampaign.com",
"customer.io": "https://customer.io",

View File

@@ -1,4 +1,7 @@
import { ReportChart } from '@/components/report-chart';
import type { IServiceReport } from '@openpanel/db';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import EditReportName from '../report/edit-report-name';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
@@ -14,18 +17,13 @@ import {
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { ReportChart } from '@/components/report-chart';
import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
import EditReportName from '../report/edit-report-name';
interface ReportEditorProps {
report: IServiceReport | null;
@@ -54,15 +52,15 @@ export default function ReportEditor({
return (
<Sheet>
<div>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center justify-between p-4">
<EditReportName />
{initialReport?.id && (
<Button
variant="outline"
icon={ShareIcon}
onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id })
}
variant="outline"
>
Share
</Button>
@@ -71,9 +69,9 @@ export default function ReportEditor({
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>
<Button
className="self-start"
icon={GanttChartSquareIcon}
variant="cta"
className="self-start"
>
Pick events
</Button>
@@ -88,23 +86,26 @@ export default function ReportEditor({
/>
<TimeWindowPicker
className="min-w-0 flex-1"
endDate={report.endDate}
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate}
onIntervalChange={(interval) =>
dispatch(changeInterval(interval))
}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
startDate={report.startDate}
value={report.range}
/>
<ReportInterval
chartType={report.chartType}
className="min-w-0 flex-1"
endDate={report.endDate}
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/>
<ReportLineType className="min-w-0 flex-1" />
</div>
@@ -114,7 +115,7 @@ export default function ReportEditor({
</div>
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
<ReportChart isEditMode report={{ ...report, projectId }} />
)}
</div>
</div>

View File

@@ -14,9 +14,11 @@ import {
LayoutDashboardIcon,
LayoutPanelTopIcon,
PlusIcon,
SearchIcon,
SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon,
UserCircleIcon,
UsersIcon,
WallpaperIcon,
} from 'lucide-react';
@@ -55,10 +57,11 @@ export default function SidebarProjectMenu({
label="Insights"
/>
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
Manage
</div>

View File

@@ -1,3 +1,9 @@
import { timeWindows } from '@openpanel/constants';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { bind } from 'bind-event-listener';
import { endOfDay, format, startOfDay } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -11,24 +17,18 @@ import {
} from '@/components/ui/dropdown-menu';
import { pushModal, useOnPushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { CalendarIcon } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress';
import { timeWindows } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import { endOfDay, format, startOfDay } from 'date-fns';
type Props = {
interface Props {
value: IChartRange;
onChange: (value: IChartRange) => void;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
onIntervalChange: (interval: IInterval) => void;
endDate: string | null;
startDate: string | null;
className?: string;
};
}
export function TimeWindowPicker({
value,
onChange,
@@ -36,6 +36,7 @@ export function TimeWindowPicker({
onStartDateChange,
endDate,
onEndDateChange,
onIntervalChange,
className,
}: Props) {
const isDateRangerPickerOpen = useRef(false);
@@ -46,10 +47,11 @@ export function TimeWindowPicker({
const handleCustom = useCallback(() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
onChange: ({ startDate, endDate, interval }) => {
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
onChange('custom');
onIntervalChange(interval);
},
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
@@ -69,7 +71,7 @@ export function TimeWindowPicker({
}
const match = Object.values(timeWindows).find(
(tw) => event.key === tw.shortcut.toLowerCase(),
(tw) => event.key === tw.shortcut.toLowerCase()
);
if (match?.key === 'custom') {
handleCustom();
@@ -84,9 +86,9 @@ export function TimeWindowPicker({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={CalendarIcon}
className={cn('justify-start', className)}
icon={CalendarIcon}
variant="outline"
>
{timeWindow?.label}
</Button>

View File

@@ -9,7 +9,6 @@ import {
DayPicker,
getDefaultClassNames,
} from 'react-day-picker';
import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -29,99 +28,93 @@ function Calendar({
return (
<DayPicker
showOutsideDays={showOutsideDays}
captionLayout={captionLayout}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
'group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col sm:flex-row relative',
defaultClassNames.months,
'relative flex flex-col gap-4 sm:flex-row',
defaultClassNames.months
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_next
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
defaultClassNames.dropdown_root
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
'absolute inset-0 bg-popover opacity-0',
defaultClassNames.dropdown
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
defaultClassNames.weekday
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
'w-(--cell-size) select-none',
defaultClassNames.week_number_header
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
'select-none text-[0.8rem] text-muted-foreground',
defaultClassNames.week_number
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
defaultClassNames.range_start
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
defaultClassNames.outside
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
defaultClassNames.disabled
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
@@ -130,9 +123,9 @@ function Calendar({
Root: ({ className, rootRef, ...props }) => {
return (
<div
className={cn(className)}
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
@@ -169,6 +162,12 @@ function Calendar({
},
...components,
}}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
showOutsideDays={showOutsideDays}
{...props}
/>
);
@@ -184,29 +183,31 @@ function CalendarDayButton({
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
if (modifiers.focused) {
ref.current?.focus();
}
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
className={cn(
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
data-day={day.date.toLocaleDateString()}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
data-range-start={modifiers.range_start}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
ref={ref}
size="icon"
variant="ghost"
{...props}
/>
);

View File

@@ -22,6 +22,7 @@ export interface DataTableProps<TData> {
title: string;
description: string;
};
onRowClick?: (row: import('@tanstack/react-table').Row<TData>) => void;
}
declare module '@tanstack/react-table' {
@@ -35,6 +36,7 @@ export function DataTable<TData>({
table,
loading,
className,
onRowClick,
empty = {
title: 'No data',
description: 'We could not find any data here yet',
@@ -78,6 +80,8 @@ export function DataTable<TData>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell