6 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
7a96e7b038 sign cooies 2026-03-09 18:29:15 +01:00
Carl-Gerhard Lindesvärd
fc256124b5 comments 2026-03-09 17:21:27 +01:00
Carl-Gerhard Lindesvärd
df0258f532 comments 2026-03-09 14:20:15 +01:00
Carl-Gerhard Lindesvärd
0f9e5f6e93 fix timepicker 2026-03-09 13:48:02 +01:00
Carl-Gerhard Lindesvärd
c9cf7901ad wip 2026-03-09 12:30:28 +01:00
Carl-Gerhard Lindesvärd
2981638893 wip 2026-03-06 13:59:03 +01:00
51 changed files with 5471 additions and 503 deletions

View File

@@ -0,0 +1,167 @@
import { googleGsc } from '@openpanel/auth';
import { db, encrypt } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { LogError } from '@/utils/errors';
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
function sanitizeOAuthQuery(
query: Record<string, unknown> | null | undefined
): Record<string, string> {
if (!query || typeof query !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(query).map(([k, v]) => [
k,
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
])
);
}
export async function gscGoogleCallback(
req: FastifyRequest,
reply: FastifyReply
) {
try {
const schema = z.object({
code: z.string(),
state: z.string(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
throw new LogError(
'Invalid GSC callback query params',
sanitizeOAuthQuery(req.query as Record<string, unknown>)
);
}
const { code, state } = query.data;
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
const rawProjectId = req.cookies.gsc_project_id ?? null;
const storedStateResult =
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
const codeVerifierResult =
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
const projectIdResult =
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
if (
!(
storedStateResult?.value &&
codeVerifierResult?.value &&
projectIdResult?.value
)
) {
throw new LogError('Missing GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
if (
!(
storedStateResult?.valid &&
codeVerifierResult?.valid &&
projectIdResult?.valid
)
) {
throw new LogError('Invalid GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
const stateStr = storedStateResult?.value;
const codeVerifierStr = codeVerifierResult?.value;
const projectIdStr = projectIdResult?.value;
if (state !== stateStr) {
throw new LogError('GSC OAuth state mismatch', {
hasState: true,
hasStoredState: true,
stateMismatch: true,
});
}
const tokens = await googleGsc.validateAuthorizationCode(
code,
codeVerifierStr
);
const accessToken = tokens.accessToken();
const refreshToken = tokens.hasRefreshToken()
? tokens.refreshToken()
: null;
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
if (!refreshToken) {
throw new LogError('No refresh token returned from Google GSC OAuth');
}
const project = await db.project.findUnique({
where: { id: projectIdStr },
select: { id: true, organizationId: true },
});
if (!project) {
throw new LogError('Project not found for GSC connection', {
projectId: projectIdStr,
});
}
await db.gscConnection.upsert({
where: { projectId: projectIdStr },
create: {
projectId: projectIdStr,
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
siteUrl: '',
},
update: {
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
lastSyncStatus: null,
lastSyncError: null,
},
});
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
const dashboardUrl =
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
return reply.redirect(redirectUrl);
} catch (error) {
req.log.error(error);
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
return redirectWithError(reply, error);
}
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);
url.pathname = '/login';
if (error instanceof LogError) {
url.searchParams.set('error', error.message);
} else {
url.searchParams.set('error', 'Failed to connect Google Search Console');
}
url.searchParams.set('correlationId', reply.request.id);
return reply.redirect(url.toString());
}

View File

@@ -36,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
@@ -194,6 +195,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});

View File

@@ -0,0 +1,12 @@
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'GET',
url: '/callback',
handler: gscGoogleCallback,
});
};
export default router;

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

View File

@@ -1,3 +1,5 @@
import { getISOWeek } from 'date-fns';
import type { IInterval } from '@openpanel/validation';
export function formatDateInterval(options: {
@@ -8,15 +10,19 @@ export function formatDateInterval(options: {
const { interval, date, short } = options;
try {
if (interval === 'hour' || interval === 'minute') {
if (short) {
return new Intl.DateTimeFormat('en-GB', {
...(!short
? {
month: '2-digit',
day: '2-digit',
}
: {}),
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
return new Intl.DateTimeFormat('en-GB', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
@@ -25,6 +31,9 @@ export function formatDateInterval(options: {
}
if (interval === 'week') {
if (short) {
return `W${getISOWeek(date)}`;
}
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -33,6 +42,12 @@ export function formatDateInterval(options: {
}
if (interval === 'day') {
if (short) {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
}).format(date);
}
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -41,7 +56,7 @@ export function formatDateInterval(options: {
}
return date.toISOString();
} catch (e) {
} catch {
return '';
}
}

View File

@@ -1,20 +1,24 @@
import { getDefaultIntervalByDates } from '@openpanel/constants';
import type { IInterval } from '@openpanel/validation';
import { endOfDay, subMonths } from 'date-fns';
import { CheckIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent } from './Modal/Container';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { subMonths } from 'date-fns';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { formatDate } from '@/utils/date';
import { CheckIcon, XIcon } from 'lucide-react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
onChange: (payload: { startDate: Date; endDate: Date }) => void;
interface Props {
onChange: (payload: {
startDate: Date;
endDate: Date;
interval: IInterval;
}) => void;
startDate?: Date;
endDate?: Date;
};
}
export default function DateRangerPicker({
onChange,
startDate: initialStartDate,
@@ -25,20 +29,20 @@ export default function DateRangerPicker({
const [endDate, setEndDate] = useState(initialEndDate);
return (
<ModalContent className="p-4 md:p-8 min-w-fit">
<ModalContent className="min-w-fit p-4 md:p-8">
<Calendar
captionLayout="dropdown"
initialFocus
mode="range"
className="mx-auto min-h-[310px] p-0 [&_table]:mx-auto [&_table]:w-auto"
defaultMonth={subMonths(
startDate ? new Date(startDate) : new Date(),
isBelowSm ? 0 : 1,
isBelowSm ? 0 : 1
)}
selected={{
from: startDate,
to: endDate,
hidden={{
after: endOfDay(new Date()),
}}
toDate={new Date()}
initialFocus
mode="range"
numberOfMonths={isBelowSm ? 1 : 2}
onSelect={(range) => {
if (range?.from) {
setStartDate(range.from);
@@ -47,33 +51,39 @@ export default function DateRangerPicker({
setEndDate(range.to);
}
}}
numberOfMonths={isBelowSm ? 1 : 2}
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0"
selected={{
from: startDate,
to: endDate,
}}
/>
<div className="col flex-col-reverse md:row gap-2">
<div className="col md:row flex-col-reverse gap-2">
<Button
icon={XIcon}
onClick={() => popModal()}
type="button"
variant="outline"
onClick={() => popModal()}
icon={XIcon}
>
Cancel
</Button>
{startDate && endDate && (
<Button
type="button"
className="md:ml-auto"
icon={startDate && endDate ? CheckIcon : XIcon}
onClick={() => {
popModal();
if (startDate && endDate) {
onChange({
startDate: startDate,
endDate: endDate,
startDate,
endDate,
interval: getDefaultIntervalByDates(
startDate.toISOString(),
endDate.toISOString()
)!,
});
}
}}
icon={startDate && endDate ? CheckIcon : XIcon}
type="button"
>
{startDate && endDate
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`

View File

@@ -0,0 +1,440 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
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 { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { Skeleton } from '@/components/skeleton';
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme';
interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
interface GscViewsChartData {
date: string;
views: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData | GscViewsChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
if (!('date' in item)) {
return null;
}
if ('views' in item && item.views != null) {
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Views</span>
<span>{item.views.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
}
const clicks = 'clicks' in item ? item.clicks : undefined;
const impressions = 'impressions' in item ? item.impressions : undefined;
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
{clicks != null && (
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
{impressions != null && (
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
</>
);
});
type Props =
| {
type: 'page';
projectId: string;
value: string;
range: IChartRange;
interval: IInterval;
}
| {
type: 'query';
projectId: string;
value: string;
range: IChartRange;
interval: IInterval;
};
export default function GscDetails(props: Props) {
const { type, projectId, value, range, interval } = props;
const trpc = useTRPC();
const dateInput = {
range,
interval,
};
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 { origin: pageOrigin, path: pagePath } =
type === 'page'
? (() => {
try {
const url = new URL(value);
return { origin: url.origin, path: url.pathname + url.search };
} catch {
return {
origin: typeof window !== 'undefined' ? window.location.origin : '',
path: value,
};
}
})()
: { origin: '', path: '' };
const pageTimeseriesQuery = useQuery(
trpc.event.pageTimeseries.queryOptions(
{ projectId, ...dateInput, origin: pageOrigin, path: pagePath },
{ enabled: type === 'page' && !!pagePath }
)
);
const data = type === 'page' ? pageQuery.data : queryQuery.data;
const isLoading =
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const timeseries = data?.timeseries ?? [];
const pageTimeseries = pageTimeseriesQuery.data ?? [];
const breakdownRows =
type === 'page'
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
: ((data as { pages?: unknown[] } | undefined)?.pages ?? []);
const breakdownKey = type === 'page' ? 'query' : 'page';
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
const maxClicks = Math.max(
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
1
);
return (
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
{value}
</SheetTitle>
</SheetHeader>
<div className="col gap-6">
{type === 'page' && (
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
{isLoading || pageTimeseriesQuery.isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<GscViewsChart
data={pageTimeseries.map((r) => ({
date: r.date,
views: r.pageviews,
}))}
/>
)}
</div>
)}
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<GscTimeseriesChart data={timeseries} />
)}
</div>
<div className="card overflow-hidden">
<div className="border-b p-4">
<h3 className="font-medium text-sm">
Top {breakdownLabel.toLowerCase()}s
</h3>
</div>
{isLoading ? (
<OverviewWidgetTable
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: 'Pos.',
width: '55px',
render: () => <Skeleton className="h-4 w-8" />,
},
]}
data={[1, 2, 3, 4, 5]}
getColumnPercentage={() => 0}
keyExtractor={(i) => String(i)}
/>
) : (
<OverviewWidgetTable
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>
);
},
},
]}
data={breakdownRows as Record<string, string | number>[]}
getColumnPercentage={(item) =>
(item.clicks as number) / maxClicks
}
keyExtractor={(item) => String(item[breakdownKey])}
/>
)}
</div>
</div>
</SheetContent>
);
}
function GscViewsChart({
data,
}: {
data: Array<{ date: string; views: number }>;
}) {
const yAxisProps = useYAxisProps();
return (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-detail-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="views"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}
function GscTimeseriesChart({
data,
}: {
data: Array<{ date: string; clicks: number; impressions: number }>;
}) {
const yAxisProps = useYAxisProps();
return (
<TooltipProvider>
<ResponsiveContainer height={160} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-detail-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(0)}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-detail-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}

View File

@@ -1,3 +1,4 @@
import PageDetails from './page-details';
import { createPushModal } from 'pushmodal';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
@@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
import { op } from '@/utils/op';
const modals = {
PageDetails,
OverviewTopPagesModal,
OverviewTopGenericModal,
RequestPasswordReset,

View File

@@ -0,0 +1,49 @@
import { GscBreakdownTable } from '@/components/page/gsc-breakdown-table';
import { GscClicksChart } from '@/components/page/gsc-clicks-chart';
import { PageViewsChart } from '@/components/page/page-views-chart';
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
type Props = {
type: 'page' | 'query';
projectId: string;
value: string;
};
export default function PageDetails({ type, projectId, value }: Props) {
return (
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
{value}
</SheetTitle>
</SheetHeader>
<div className="col gap-6">
{type === 'page' &&
(() => {
let origin: string;
let path: string;
try {
const url = new URL(value);
origin = url.origin;
path = url.pathname + url.search;
} catch {
// value is path-only (e.g. "/docs/foo")
origin =
typeof window !== 'undefined' ? window.location.origin : '';
path = value;
}
return (
<PageViewsChart
origin={origin}
path={path}
projectId={projectId}
/>
);
})()}
<GscClicksChart projectId={projectId} type={type} value={value} />
<GscBreakdownTable projectId={projectId} type={type} value={value} />
</div>
</SheetContent>
);
}

View File

@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdSeoRoute =
AppOrganizationIdProjectIdSeoRouteImport.update({
id: '/seo',
path: '/seo',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdReportsRoute =
AppOrganizationIdProjectIdReportsRouteImport.update({
id: '/reports',
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
path: '/imports',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
id: '/gsc',
path: '/gsc',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
id: '/events',
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -747,6 +765,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
@@ -789,6 +808,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/realtime'
| '/_app/$organizationId/$projectId/references'
| '/_app/$organizationId/$projectId/reports'
| '/_app/$organizationId/$projectId/seo'
| '/_app/$organizationId/$projectId/sessions'
| '/_app/$organizationId/integrations'
| '/_app/$organizationId/integrations/_tabs'
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
| '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/seo': {
id: '/_app/$organizationId/$projectId/seo'
path: '/seo'
fullPath: '/$organizationId/$projectId/seo'
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/reports': {
id: '/_app/$organizationId/$projectId/reports'
path: '/reports'
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
path: '/gsc'
fullPath: '/$organizationId/$projectId/settings/gsc'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/events': {
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
path: '/events'
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsGscRoute:
AppOrganizationIdProjectIdSettingsTabsGscRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdReferencesRoute,
AppOrganizationIdProjectIdReportsRoute:
AppOrganizationIdProjectIdReportsRoute,
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
AppOrganizationIdProjectIdSessionsRoute:
AppOrganizationIdProjectIdSessionsRoute,
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,

View File

@@ -1,349 +1,22 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { PagesTable } from '@/components/pages/table';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { FloatingPagination } from '@/components/pagination-floating';
import { ReportChart } from '@/components/report-chart';
import { Skeleton } from '@/components/skeleton';
import { Input } from '@/components/ui/input';
import { TableButtons } from '@/components/ui/table';
import { useAppContext } from '@/hooks/use-app-context';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs';
import { memo, useEffect, useMemo, useState } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.PAGES),
},
],
};
},
head: () => ({
meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }],
}),
});
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const take = 20;
const { range, interval } = useOverviewOptions();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(1),
);
const { debouncedSearch, setSearch, search } = useSearchQueryState();
// Track if we should use backend search (when client-side filtering finds nothing)
const [useBackendSearch, setUseBackendSearch] = useState(false);
// Reset to client-side filtering when search changes
useEffect(() => {
setUseBackendSearch(false);
setCursor(1);
}, [debouncedSearch, setCursor]);
// Query for all pages (without search) - used for client-side filtering
const allPagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: undefined, // No search - get all pages
range,
interval,
},
{
placeholderData: keepPreviousData,
},
),
);
// Query for backend search (only when client-side filtering finds nothing)
const backendSearchQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: debouncedSearch || undefined,
range,
interval,
},
{
placeholderData: keepPreviousData,
enabled: useBackendSearch && !!debouncedSearch,
},
),
);
// Client-side filtering: filter all pages by search query
const clientSideFiltered = useMemo(() => {
if (!debouncedSearch || useBackendSearch) {
return allPagesQuery.data ?? [];
}
const searchLower = debouncedSearch.toLowerCase();
return (allPagesQuery.data ?? []).filter(
(page) =>
page.path.toLowerCase().includes(searchLower) ||
page.origin.toLowerCase().includes(searchLower),
);
}, [allPagesQuery.data, debouncedSearch, useBackendSearch]);
// Check if client-side filtering found results
useEffect(() => {
if (
debouncedSearch &&
!useBackendSearch &&
allPagesQuery.isSuccess &&
clientSideFiltered.length === 0
) {
// No results from client-side filtering, switch to backend search
setUseBackendSearch(true);
}
}, [
debouncedSearch,
useBackendSearch,
allPagesQuery.isSuccess,
clientSideFiltered.length,
]);
// Determine which data source to use
const allData = useBackendSearch
? (backendSearchQuery.data ?? [])
: clientSideFiltered;
const isLoading = useBackendSearch
? backendSearchQuery.isLoading
: allPagesQuery.isLoading;
// Client-side pagination: slice the items based on cursor
const startIndex = (cursor - 1) * take;
const endIndex = startIndex + take;
const data = allData.slice(startIndex, endIndex);
const totalPages = Math.ceil(allData.length / take);
return (
<PageContainer>
<PageHeader
title="Pages"
description="Access all your pages here"
className="mb-8"
/>
<TableButtons>
<OverviewRange />
<OverviewInterval />
<Input
className="self-auto"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(1);
}}
/>
</TableButtons>
{data.length === 0 && !isLoading && (
<FullPageEmptyState
title="No pages"
description={
debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web sdk to your site to get pages here.'
}
/>
)}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PageCardSkeleton />
<PageCardSkeleton />
<PageCardSkeleton />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.map((page) => {
return (
<PageCard
key={page.origin + page.path}
page={page}
range={range}
interval={interval}
projectId={projectId}
/>
);
})}
</div>
{allData.length !== 0 && (
<div className="p-4">
<FloatingPagination
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
canNextPage={cursor < totalPages}
canPreviousPage={cursor > 1}
pageIndex={cursor - 1}
nextPage={() => {
setCursor((p) => Math.min(p + 1, totalPages));
}}
previousPage={() => {
setCursor((p) => Math.max(p - 1, 1));
}}
/>
</div>
)}
<PageHeader title="Pages" description="Access all your pages here" className="mb-8" />
<PagesTable projectId={projectId} />
</PageContainer>
);
}
const PageCard = memo(
({
page,
range,
interval,
projectId,
}: {
page: RouterOutputs['event']['pages'][number];
range: IChartRange;
interval: IInterval;
projectId: string;
}) => {
const number = useNumber();
const { apiUrl } = useAppContext();
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<img
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
alt={page.title}
className="size-10 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
<div className="col min-w-0">
<div className="font-medium leading-[28px] truncate">
{page.title}
</div>
<a
target="_blank"
rel="noreferrer"
href={`${page.origin}${page.path}`}
className="text-muted-foreground font-mono truncate hover:underline"
>
{page.path}
</a>
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.avg_duration, 'min')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
duration
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.formatWithUnit(page.bounce_rate / 100, '%')}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
bounce rate
</div>
</div>
<div className="center-center col flex-1 p-4 py-2">
<div className="font-medium text-xl font-mono">
{number.format(page.sessions)}
</div>
<div className="text-muted-foreground whitespace-nowrap text-sm">
sessions
</div>
</div>
</div>
<ReportChart
options={{
hideXAxis: true,
hideYAxis: true,
aspectRatio: 0.15,
}}
report={{
breakdowns: [],
metric: 'sum',
range,
interval,
previous: true,
chartType: 'linear',
projectId,
series: [
{
type: 'event',
id: 'A',
name: 'screen_view',
segment: 'event',
filters: [
{
id: 'path',
name: 'path',
value: [page.path],
operator: 'is',
},
{
id: 'origin',
name: 'origin',
value: [page.origin],
operator: 'is',
},
],
},
],
}}
/>
</div>
);
},
);
const PageCardSkeleton = memo(() => {
return (
<div className="card">
<div className="row gap-4 justify-between p-4 py-2 items-center">
<div className="row gap-2 items-center h-16">
<Skeleton className="size-10 rounded-sm" />
<div className="col min-w-0">
<Skeleton className="h-3 w-32 mb-2" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
<div className="row border-y">
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-16 mb-1" />
<Skeleton className="h-4 w-12" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-12 mb-1" />
<Skeleton className="h-4 w-16" />
</div>
<div className="center-center col flex-1 p-4 py-2">
<Skeleton className="h-6 w-14 mb-1" />
<Skeleton className="h-4 w-14" />
</div>
</div>
<div className="p-4">
<Skeleton className="h-16 w-full rounded" />
</div>
</div>
);
});

View File

@@ -0,0 +1,821 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { GscCannibalization } from '@/components/page/gsc-cannibalization';
import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark';
import { GscPositionChart } from '@/components/page/gsc-position-chart';
import { PagesInsights } from '@/components/page/pages-insights';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Pagination } from '@/components/pagination';
import {
useYAxisProps,
X_AXIS_STYLE_PROPS,
} from '@/components/report-chart/common/axis';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Skeleton } from '@/components/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { getChartColor } from '@/utils/theme';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({
component: SeoPage,
head: () => ({
meta: [{ title: createProjectTitle('SEO') }],
}),
});
interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData,
Record<string, unknown>
>(({ data }) => {
const item = data[0];
if (!item) {
return null;
}
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{item.clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
<ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span>
<span>{item.impressions.toLocaleString()}</span>
</div>
</ChartTooltipItem>
</>
);
});
function SeoPage() {
const { projectId, organizationId } = useAppParams();
const trpc = useTRPC();
const navigate = useNavigate();
const { range, startDate, endDate, interval } = useOverviewOptions();
const dateInput = {
range,
interval,
startDate,
endDate,
};
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId })
);
const connection = connectionQuery.data;
const isConnected = connection?.siteUrl;
const overviewQuery = useQuery(
trpc.gsc.getOverview.queryOptions(
{ projectId, ...dateInput, interval: interval ?? 'day' },
{ enabled: !!isConnected }
)
);
const pagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{ projectId, ...dateInput, limit: 50 },
{ enabled: !!isConnected }
)
);
const queriesQuery = useQuery(
trpc.gsc.getQueries.queryOptions(
{ projectId, ...dateInput, limit: 50 },
{ enabled: !!isConnected }
)
);
const searchEnginesQuery = useQuery(
trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput })
);
const aiEnginesQuery = useQuery(
trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput })
);
const previousOverviewQuery = useQuery(
trpc.gsc.getPreviousOverview.queryOptions(
{ projectId, ...dateInput, interval: interval ?? 'day' },
{ enabled: !!isConnected }
)
);
const [pagesPage, setPagesPage] = useState(0);
const [queriesPage, setQueriesPage] = useState(0);
const pageSize = 15;
const [pagesSearch, setPagesSearch] = useState('');
const [queriesSearch, setQueriesSearch] = useState('');
const pages = pagesQuery.data ?? [];
const queries = queriesQuery.data ?? [];
const filteredPages = useMemo(() => {
if (!pagesSearch.trim()) {
return pages;
}
const q = pagesSearch.toLowerCase();
return pages.filter((row) => {
return String(row.page).toLowerCase().includes(q);
});
}, [pages, pagesSearch]);
const filteredQueries = useMemo(() => {
if (!queriesSearch.trim()) {
return queries;
}
const q = queriesSearch.toLowerCase();
return queries.filter((row) => {
return String(row.query).toLowerCase().includes(q);
});
}, [queries, queriesSearch]);
const paginatedPages = useMemo(
() => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize),
[filteredPages, pagesPage, pageSize]
);
const paginatedQueries = useMemo(
() =>
filteredQueries.slice(
queriesPage * pageSize,
(queriesPage + 1) * pageSize
),
[filteredQueries, queriesPage, pageSize]
);
const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1;
const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1;
if (connectionQuery.isLoading) {
return (
<PageContainer>
<PageHeader description="Google Search Console data" title="SEO" />
<div className="mt-8 space-y-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
</PageContainer>
);
}
if (!isConnected) {
return (
<FullPageEmptyState
className="pt-[20vh]"
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
icon={SearchIcon}
title="No SEO data yet"
>
<Button
onClick={() =>
navigate({
to: '/$organizationId/$projectId/settings/gsc',
params: { organizationId, projectId },
})
}
>
Connect Google Search Console
</Button>
</FullPageEmptyState>
);
}
const overview = overviewQuery.data ?? [];
const prevOverview = previousOverviewQuery.data ?? [];
const sumOverview = (rows: typeof overview) =>
rows.reduce(
(acc, row) => ({
clicks: acc.clicks + row.clicks,
impressions: acc.impressions + row.impressions,
ctr: acc.ctr + row.ctr,
position: acc.position + row.position,
}),
{ clicks: 0, impressions: 0, ctr: 0, position: 0 }
);
const totals = sumOverview(overview);
const prevTotals = sumOverview(prevOverview);
const n = Math.max(overview.length, 1);
const pn = Math.max(prevOverview.length, 1);
return (
<PageContainer>
<PageHeader
actions={
<>
<OverviewRange />
<OverviewInterval />
</>
}
description={`Search performance for ${connection.siteUrl}`}
title="SEO"
/>
<div className="mt-8 space-y-8">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
<OverviewMetricCard
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
id="clicks"
isLoading={overviewQuery.isLoading}
label="Clicks"
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.impressions,
date: r.date,
}))}
id="impressions"
isLoading={overviewQuery.isLoading}
label="Impressions"
metric={{
current: totals.impressions,
previous: prevTotals.impressions,
}}
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.ctr * 100,
date: r.date,
}))}
id="ctr"
isLoading={overviewQuery.isLoading}
label="Avg CTR"
metric={{
current: (totals.ctr / n) * 100,
previous: (prevTotals.ctr / pn) * 100,
}}
unit="%"
/>
<OverviewMetricCard
data={overview.map((r) => ({
current: r.position,
date: r.date,
}))}
id="position"
inverted
isLoading={overviewQuery.isLoading}
label="Avg Position"
metric={{
current: totals.position / n,
previous: prevTotals.position / pn,
}}
/>
</div>
<SearchEngines
engines={searchEnginesQuery.data?.engines ?? []}
isLoading={searchEnginesQuery.isLoading}
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
total={searchEnginesQuery.data?.total ?? 0}
/>
<AiEngines
engines={aiEnginesQuery.data?.engines ?? []}
isLoading={aiEnginesQuery.isLoading}
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
total={aiEnginesQuery.data?.total ?? 0}
/>
</div>
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GscPositionChart
data={overview}
isLoading={overviewQuery.isLoading}
/>
<GscCtrBenchmark
data={pagesQuery.data ?? []}
isLoading={pagesQuery.isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<GscTable
isLoading={pagesQuery.isLoading}
keyField="page"
keyLabel="Page"
maxClicks={Math.max(...paginatedPages.map((p) => p.clicks), 1)}
onNextPage={() =>
setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1))
}
onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))}
onRowClick={(value) =>
pushModal('PageDetails', { type: 'page', projectId, value })
}
onSearchChange={(v) => {
setPagesSearch(v);
setPagesPage(0);
}}
pageCount={pagesPageCount}
pageIndex={pagesPage}
pageSize={pageSize}
rows={paginatedPages}
searchPlaceholder="Search pages"
searchValue={pagesSearch}
title="Top pages"
totalCount={filteredPages.length}
/>
<GscTable
isLoading={queriesQuery.isLoading}
keyField="query"
keyLabel="Query"
maxClicks={Math.max(...paginatedQueries.map((q) => q.clicks), 1)}
onNextPage={() =>
setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1))
}
onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))}
onRowClick={(value) =>
pushModal('PageDetails', { type: 'query', projectId, value })
}
onSearchChange={(v) => {
setQueriesSearch(v);
setQueriesPage(0);
}}
pageCount={queriesPageCount}
pageIndex={queriesPage}
pageSize={pageSize}
rows={paginatedQueries}
searchPlaceholder="Search queries"
searchValue={queriesSearch}
title="Top queries"
totalCount={filteredQueries.length}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GscCannibalization
endDate={endDate ?? undefined}
interval={interval ?? 'day'}
projectId={projectId}
range={range}
startDate={startDate ?? undefined}
/>
<PagesInsights projectId={projectId} />
</div>
</div>
</PageContainer>
);
}
function TrafficSourceWidget({
title,
engines,
total,
previousTotal,
isLoading,
emptyMessage,
}: {
title: string;
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
emptyMessage: string;
}) {
const displayed =
engines.length > 8
? [
...engines.slice(0, 7),
{
name: 'Others',
sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0),
},
]
: engines.slice(0, 8);
const max = displayed[0]?.sessions ?? 1;
const pctChange =
previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null;
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b p-4">
<h3 className="font-medium text-sm">{title}</h3>
{!isLoading && total > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium font-mono text-sm tabular-nums">
{total.toLocaleString()}
</span>
{pctChange !== null && (
<span
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
>
{pctChange >= 0 ? '+' : ''}
{pctChange.toFixed(1)}%
</span>
)}
</div>
)}
</div>
<div className="grid grid-cols-2">
{isLoading &&
[1, 2, 3, 4].map((i) => (
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
<div className="size-4 animate-pulse rounded-sm bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
</div>
))}
{!isLoading && engines.length === 0 && (
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
{emptyMessage}
</p>
)}
{!isLoading &&
displayed.map((engine) => {
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
const barPct = (engine.sessions / max) * 100;
return (
<div className="relative px-4 py-2.5" key={engine.name}>
<div
className="absolute inset-y-0 left-0 bg-muted/50"
style={{ width: `${barPct}%` }}
/>
<div className="relative flex items-center gap-2">
{engine.name !== 'Others' && (
<SerieIcon
className="size-3.5 shrink-0 rounded-sm"
name={engine.name}
/>
)}
<span className="min-w-0 flex-1 truncate text-xs capitalize">
{engine.name.replace(/\..+$/, '')}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{engine.sessions.toLocaleString()}
</span>
<span className="shrink-0 font-mono text-muted-foreground text-xs">
{pct.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
function SearchEngines(props: {
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
}) {
return (
<TrafficSourceWidget
{...props}
emptyMessage="No search traffic in this period"
title="Search engines"
/>
);
}
function AiEngines(props: {
engines: Array<{ name: string; sessions: number }>;
total: number;
previousTotal: number;
isLoading: boolean;
}) {
return (
<TrafficSourceWidget
{...props}
emptyMessage="No AI traffic in this period"
title="AI referrals"
/>
);
}
function GscChart({
data,
isLoading,
}: {
data: Array<{ date: string; clicks: number; impressions: number }>;
isLoading: boolean;
}) {
const color = getChartColor(0);
const yAxisProps = useYAxisProps();
return (
<div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<TooltipProvider>
<ResponsiveContainer height={200} width="100%">
<ComposedChart data={data}>
<defs>
<filter
height="140%"
id="gsc-line-glow"
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="5" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA slope="0.5" type="linear" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
<CartesianGrid
className="stroke-border"
horizontal
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
{...X_AXIS_STYLE_PROPS}
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
type="category"
/>
<YAxis {...yAxisProps} yAxisId="left" />
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
<GscTooltip />
<Line
dataKey="clicks"
dot={false}
filter="url(#gsc-line-glow)"
isAnimationActive={false}
stroke={color}
strokeWidth={2}
type="monotone"
yAxisId="left"
/>
<Line
dataKey="impressions"
dot={false}
filter="url(#gsc-line-glow)"
isAnimationActive={false}
stroke={getChartColor(1)}
strokeWidth={2}
type="monotone"
yAxisId="right"
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
)}
</div>
);
}
interface GscTableRow {
clicks: number;
impressions: number;
ctr: number;
position: number;
[key: string]: string | number;
}
function GscTable({
title,
rows,
keyField,
keyLabel,
maxClicks,
isLoading,
onRowClick,
searchValue,
onSearchChange,
searchPlaceholder,
totalCount,
pageIndex,
pageSize,
pageCount,
onPreviousPage,
onNextPage,
}: {
title: string;
rows: GscTableRow[];
keyField: string;
keyLabel: string;
maxClicks: number;
isLoading: boolean;
onRowClick?: (value: string) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
totalCount?: number;
pageIndex?: number;
pageSize?: number;
pageCount?: number;
onPreviousPage?: () => void;
onNextPage?: () => void;
}) {
const showPagination =
totalCount != null &&
pageSize != null &&
pageCount != null &&
onPreviousPage != null &&
onNextPage != null &&
pageIndex != null;
const canPreviousPage = (pageIndex ?? 0) > 0;
const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1;
const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0;
const rangeEnd = Math.min(
(pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0),
totalCount ?? 0
);
if (isLoading) {
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b p-4">
<h3 className="font-medium text-sm">{title}</h3>
</div>
<OverviewWidgetTable
columns={[
{
name: keyLabel,
width: 'w-full',
render: () => <Skeleton className="h-4 w-2/3" />,
},
{
name: 'Clicks',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'Impr.',
width: '70px',
render: () => <Skeleton className="h-4 w-10" />,
},
{
name: 'CTR',
width: '60px',
render: () => <Skeleton className="h-4 w-8" />,
},
{
name: 'Pos.',
width: '55px',
render: () => <Skeleton className="h-4 w-8" />,
},
]}
data={[1, 2, 3, 4, 5]}
getColumnPercentage={() => 0}
keyExtractor={(i) => String(i)}
/>
</div>
);
}
return (
<div className="card">
<div className="border-b">
<div className="flex items-center justify-between px-4 py-3">
<h3 className="font-medium text-sm">{title}</h3>
{showPagination && (
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-muted-foreground text-xs">
{totalCount === 0
? '0 results'
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
</span>
<Pagination
canNextPage={canNextPage}
canPreviousPage={canPreviousPage}
nextPage={onNextPage}
pageIndex={pageIndex}
previousPage={onPreviousPage}
/>
</div>
)}
</div>
{onSearchChange != null && (
<div className="relative border-t">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="rounded-none border-0 border-y bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder ?? 'Search'}
type="search"
value={searchValue ?? ''}
/>
</div>
)}
</div>
<OverviewWidgetTable
columns={[
{
name: keyLabel,
width: 'w-full',
render(item) {
return (
<div className="min-w-0 overflow-hidden">
<button
className="block w-full truncate text-left font-mono text-xs hover:underline"
onClick={() => onRowClick?.(String(item[keyField]))}
type="button"
>
{String(item[keyField])}
</button>
</div>
);
},
},
{
name: 'Clicks',
width: '70px',
getSortValue: (item) => item.clicks,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.clicks.toLocaleString()}
</span>
);
},
},
{
name: 'Impr.',
width: '70px',
getSortValue: (item) => item.impressions,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.impressions.toLocaleString()}
</span>
);
},
},
{
name: 'CTR',
width: '60px',
getSortValue: (item) => item.ctr,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{(item.ctr * 100).toFixed(1)}%
</span>
);
},
},
{
name: 'Pos.',
width: '55px',
getSortValue: (item) => item.position,
render(item) {
return (
<span className="font-mono font-semibold text-xs">
{item.position.toFixed(1)}
</span>
);
},
},
]}
data={rows}
getColumnPercentage={(item) => item.clicks / maxClicks}
keyExtractor={(item) => String(item[keyField])}
/>
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
)({
component: GscSettings,
});
function GscSettings() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [selectedSite, setSelectedSite] = useState('');
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions(
{ projectId },
{ refetchInterval: 5000 }
)
);
const sitesQuery = useQuery(
trpc.gsc.getSites.queryOptions(
{ projectId },
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
)
);
const initiateOAuth = useMutation(
trpc.gsc.initiateOAuth.mutationOptions({
onSuccess: (data) => {
window.location.href = data.url;
},
onError: () => {
toast.error('Failed to initiate Google Search Console connection');
},
})
);
const selectSite = useMutation(
trpc.gsc.selectSite.mutationOptions({
onSuccess: () => {
toast.success('Site connected', {
description: 'Backfill of 6 months of data has started.',
});
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to select site');
},
})
);
const disconnect = useMutation(
trpc.gsc.disconnect.mutationOptions({
onSuccess: () => {
toast.success('Disconnected from Google Search Console');
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to disconnect');
},
})
);
const connection = connectionQuery.data;
if (connectionQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
</div>
);
}
// Not connected at all
if (!connection) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connect your Google Search Console property to import search
performance data.
</p>
</div>
<div className="flex flex-col gap-4 rounded-lg border p-6">
<p className="text-muted-foreground text-sm">
You will be redirected to Google to authorize access. Only read-only
access to Search Console data is requested.
</p>
<Button
className="w-fit"
disabled={initiateOAuth.isPending}
onClick={() => initiateOAuth.mutate({ projectId })}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect Google Search Console
</Button>
</div>
</div>
);
}
// Connected but no site selected yet
if (!connection.siteUrl) {
const sites = sitesQuery.data ?? [];
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Select a property</h3>
<p className="mt-1 text-muted-foreground text-sm">
Choose which Google Search Console property to connect to this
project.
</p>
</div>
<div className="space-y-4 rounded-lg border p-6">
{sitesQuery.isLoading ? (
<Skeleton className="h-10 w-full" />
) : sites.length === 0 ? (
<p className="text-muted-foreground text-sm">
No Search Console properties found for this Google account.
</p>
) : (
<>
<Select onValueChange={setSelectedSite} value={selectedSite}>
<SelectTrigger>
<SelectValue placeholder="Select a property..." />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem key={site.siteUrl} value={site.siteUrl}>
{site.siteUrl}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedSite || selectSite.isPending}
onClick={() =>
selectSite.mutate({ projectId, siteUrl: selectedSite })
}
>
{selectSite.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect property
</Button>
</>
)}
</div>
<Button
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="ghost"
>
Cancel
</Button>
</div>
);
}
// Token expired — show reconnect prompt
if (connection.lastSyncStatus === 'token_expired') {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connected to Google Search Console.
</p>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
<div className="flex items-center gap-2 text-destructive">
<XCircleIcon className="h-4 w-4" />
<span className="font-medium text-sm">Authorization expired</span>
</div>
<p className="text-muted-foreground text-sm">
Your Google Search Console authorization has expired or been
revoked. Please reconnect to continue syncing data.
</p>
{connection.lastSyncError && (
<p className="break-words font-mono text-muted-foreground text-xs">
{connection.lastSyncError}
</p>
)}
<Button
className="w-fit"
disabled={initiateOAuth.isPending}
onClick={() => initiateOAuth.mutate({ projectId })}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Reconnect Google Search Console
</Button>
</div>
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="ghost"
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}
// Fully connected
const syncStatusIcon =
connection.lastSyncStatus === 'success' ? (
<CheckCircleIcon className="h-4 w-4" />
) : connection.lastSyncStatus === 'error' ? (
<XCircleIcon className="h-4 w-4" />
) : null;
const syncStatusVariant =
connection.lastSyncStatus === 'success'
? 'success'
: connection.lastSyncStatus === 'error'
? 'destructive'
: 'secondary';
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-lg">Google Search Console</h3>
<p className="mt-1 text-muted-foreground text-sm">
Connected to Google Search Console.
</p>
</div>
<div className="divide-y rounded-lg border">
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Property</div>
<div className="font-mono text-muted-foreground text-sm">
{connection.siteUrl}
</div>
</div>
{connection.backfillStatus && (
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Backfill</div>
<Badge
className="capitalize"
variant={
connection.backfillStatus === 'completed'
? 'success'
: connection.backfillStatus === 'failed'
? 'destructive'
: connection.backfillStatus === 'running'
? 'default'
: 'secondary'
}
>
{connection.backfillStatus === 'running' && (
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
)}
{connection.backfillStatus}
</Badge>
</div>
)}
{connection.lastSyncedAt && (
<div className="flex items-center justify-between p-4">
<div className="font-medium text-sm">Last synced</div>
<div className="flex items-center gap-2">
{connection.lastSyncStatus && (
<Badge
className="capitalize"
variant={syncStatusVariant as any}
>
{syncStatusIcon}
{connection.lastSyncStatus}
</Badge>
)}
<span className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
addSuffix: true,
})}
</span>
</div>
</div>
)}
{connection.lastSyncError && (
<div className="p-4">
<div className="font-medium text-destructive text-sm">
Last error
</div>
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
{connection.lastSyncError}
</div>
</div>
)}
</div>
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate({ projectId })}
size="sm"
variant="destructive"
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}

View File

@@ -45,6 +45,7 @@ function ProjectDashboard() {
{ id: 'tracking', label: 'Tracking script' },
{ id: 'widgets', label: 'Widgets' },
{ id: 'imports', label: 'Imports' },
{ id: 'gsc', label: 'Google Search' },
];
const handleTabChange = (tabId: string) => {

View File

@@ -78,6 +78,11 @@ export async function bootCron() {
type: 'onboarding',
pattern: '0 * * * *',
},
{
name: 'gscSync',
type: 'gscSync',
pattern: '0 3 * * *',
},
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -6,6 +6,7 @@ import {
type EventsQueuePayloadIncomingEvent,
cronQueue,
eventsGroupQueues,
gscQueue,
importQueue,
insightsQueue,
miscQueue,
@@ -20,6 +21,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { Worker as GroupWorker } from 'groupmq';
import { cronJob } from './jobs/cron';
import { gscJob } from './jobs/gsc';
import { incomingEvent } from './jobs/events.incoming-event';
import { importJob } from './jobs/import';
import { insightsProjectJob } from './jobs/insights';
@@ -59,6 +61,7 @@ function getEnabledQueues(): QueueName[] {
'misc',
'import',
'insights',
'gsc',
];
}
@@ -208,6 +211,17 @@ export async function bootWorkers() {
logger.info('Started worker for insights', { concurrency });
}
// Start gsc worker
if (enabledQueues.includes('gsc')) {
const concurrency = getConcurrencyFor('gsc', 5);
const gscWorker = new Worker(gscQueue.name, gscJob, {
...workerOptions,
concurrency,
});
workers.push(gscWorker);
logger.info('Started worker for gsc', { concurrency });
}
if (workers.length === 0) {
logger.warn(
'No workers started. Check ENABLED_QUEUES environment variable.',

View File

@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
import { gscSyncAllJob } from './gsc';
import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'onboarding': {
return await onboardingJob(job);
}
case 'gscSync': {
return await gscSyncAllJob();
}
}
}

142
apps/worker/src/jobs/gsc.ts Normal file
View File

@@ -0,0 +1,142 @@
import { db, syncGscData } from '@openpanel/db';
import { gscQueue } from '@openpanel/queue';
import type { GscQueuePayload } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { logger } from '../utils/logger';
const BACKFILL_MONTHS = 6;
const CHUNK_DAYS = 14;
export async function gscJob(job: Job<GscQueuePayload>) {
switch (job.data.type) {
case 'gscProjectSync':
return gscProjectSyncJob(job.data.payload.projectId);
case 'gscProjectBackfill':
return gscProjectBackfillJob(job.data.payload.projectId);
}
}
async function gscProjectSyncJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC sync skipped: no connection or siteUrl', { projectId });
return;
}
try {
// Sync rolling 3-day window (GSC data can arrive late)
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 2); // 3 days total
await syncGscData(projectId, startDate, endDate);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC sync completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC sync failed', { projectId, error });
throw error;
}
}
async function gscProjectBackfillJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC backfill skipped: no connection or siteUrl', { projectId });
return;
}
await db.gscConnection.update({
where: { projectId },
data: { backfillStatus: 'running' },
});
try {
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS);
// Process in chunks to avoid timeouts and respect API limits
let chunkEnd = new Date(endDate);
while (chunkEnd > startDate) {
const chunkStart = new Date(chunkEnd);
chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1);
if (chunkStart < startDate) {
chunkStart.setTime(startDate.getTime());
}
logger.info('GSC backfill chunk', {
projectId,
from: chunkStart.toISOString().slice(0, 10),
to: chunkEnd.toISOString().slice(0, 10),
});
await syncGscData(projectId, chunkStart, chunkEnd);
chunkEnd = new Date(chunkStart);
chunkEnd.setDate(chunkEnd.getDate() - 1);
}
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'completed',
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC backfill completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'failed',
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC backfill failed', { projectId, error });
throw error;
}
}
export async function gscSyncAllJob() {
const connections = await db.gscConnection.findMany({
where: {
siteUrl: { not: '' },
},
select: { projectId: true },
});
logger.info('GSC nightly sync: enqueuing projects', {
count: connections.length,
});
for (const conn of connections) {
await gscQueue.add('gscProjectSync', {
type: 'gscProjectSync',
payload: { projectId: conn.projectId },
});
}
}

View File

@@ -1,18 +0,0 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
);

View File

@@ -1,6 +1,7 @@
import { GitHub } from 'arctic';
export type { OAuth2Tokens } from 'arctic';
import * as Arctic from 'arctic';
export { Arctic };
@@ -8,11 +9,17 @@ export { Arctic };
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID ?? '',
process.env.GITHUB_CLIENT_SECRET ?? '',
process.env.GITHUB_REDIRECT_URI ?? '',
process.env.GITHUB_REDIRECT_URI ?? ''
);
export const google = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GOOGLE_REDIRECT_URI ?? '',
process.env.GOOGLE_REDIRECT_URI ?? ''
);
export const googleGsc = new Arctic.Google(
process.env.GOOGLE_CLIENT_ID ?? '',
process.env.GOOGLE_CLIENT_SECRET ?? '',
process.env.GSC_GOOGLE_REDIRECT_URI ?? ''
);

View File

@@ -0,0 +1,85 @@
import fs from 'node:fs';
import path from 'node:path';
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
import { getIsCluster } from './helpers';
export async function up() {
const isClustered = getIsCluster();
const commonMetricColumns = [
'`clicks` UInt32 CODEC(Delta(4), LZ4)',
'`impressions` UInt32 CODEC(Delta(4), LZ4)',
'`ctr` Float32 CODEC(Gorilla, LZ4)',
'`position` Float32 CODEC(Gorilla, LZ4)',
'`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)',
];
const sqls: string[] = [
// Daily totals — accurate overview numbers
...createTable({
name: 'gsc_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
...commonMetricColumns,
],
orderBy: ['project_id', 'date'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
// Per-page breakdown
...createTable({
name: 'gsc_pages_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
'`page` String CODEC(ZSTD(3))',
...commonMetricColumns,
],
orderBy: ['project_id', 'date', 'page'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
// Per-query breakdown
...createTable({
name: 'gsc_queries_daily',
columns: [
'`project_id` String CODEC(ZSTD(3))',
'`date` Date CODEC(Delta(2), LZ4)',
'`query` String CODEC(ZSTD(3))',
...commonMetricColumns,
],
orderBy: ['project_id', 'date', 'query'],
partitionBy: 'toYYYYMM(date)',
engine: 'ReplacingMergeTree(synced_at)',
distributionHash: 'cityHash64(project_id)',
replicatedVersion: '1',
isClustered,
}),
];
fs.writeFileSync(
path.join(__filename.replace('.ts', '.sql')),
sqls
.map((sql) =>
sql
.trim()
.replace(/;$/, '')
.replace(/\n{2,}/g, '\n')
.concat(';'),
)
.join('\n\n---\n\n'),
);
if (!process.argv.includes('--dry')) {
await runClickhouseMigrationCommands(sqls);
}
}

View File

@@ -31,3 +31,5 @@ export * from './src/services/overview.service';
export * from './src/services/pages.service';
export * from './src/services/insights';
export * from './src/session-context';
export * from './src/gsc';
export * from './src/encryption';

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "public"."gsc_connections" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"projectId" TEXT NOT NULL,
"siteUrl" TEXT NOT NULL DEFAULT '',
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"accessTokenExpiresAt" TIMESTAMP(3),
"lastSyncedAt" TIMESTAMP(3),
"lastSyncStatus" TEXT,
"lastSyncError" TEXT,
"backfillStatus" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gsc_connections_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "gsc_connections_projectId_key" ON "public"."gsc_connections"("projectId");
-- AddForeignKey
ALTER TABLE "public"."gsc_connections" ADD CONSTRAINT "gsc_connections_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -203,6 +203,7 @@ model Project {
notificationRules NotificationRule[]
notifications Notification[]
imports Import[]
gscConnection GscConnection?
// When deleteAt > now(), the project will be deleted
deleteAt DateTime?
@@ -612,6 +613,24 @@ model InsightEvent {
@@map("insight_events")
}
model GscConnection {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
siteUrl String @default("")
accessToken String
refreshToken String
accessTokenExpiresAt DateTime?
lastSyncedAt DateTime?
lastSyncStatus String?
lastSyncError String?
backfillStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("gsc_connections")
}
model EmailUnsubscribe {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String

View File

@@ -58,6 +58,9 @@ export const TABLE_NAMES = {
sessions: 'sessions',
events_imports: 'events_imports',
session_replay_chunks: 'session_replay_chunks',
gsc_daily: 'gsc_daily',
gsc_pages_daily: 'gsc_pages_daily',
gsc_queries_daily: 'gsc_queries_daily',
};
/**

View File

@@ -0,0 +1,44 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const ENCODING = 'base64';
function getKey(): Buffer {
const raw = process.env.ENCRYPTION_KEY;
if (!raw) {
throw new Error('ENCRYPTION_KEY environment variable is not set');
}
const buf = Buffer.from(raw, 'hex');
if (buf.length !== 32) {
throw new Error(
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'
);
}
return buf;
}
export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Format: base64(iv + tag + ciphertext)
return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
}
export function decrypt(ciphertext: string): string {
const key = getKey();
const buf = Buffer.from(ciphertext, ENCODING);
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final('utf8');
}

554
packages/db/src/gsc.ts Normal file
View File

@@ -0,0 +1,554 @@
import { cacheable } from '@openpanel/redis';
import { originalCh } from './clickhouse/client';
import { decrypt, encrypt } from './encryption';
import { db } from './prisma-client';
export interface GscSite {
siteUrl: string;
permissionLevel: string;
}
async function refreshGscToken(
refreshToken: string
): Promise<{ accessToken: string; expiresAt: Date }> {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to refresh GSC token: ${text}`);
}
const data = (await res.json()) as {
access_token: string;
expires_in: number;
};
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
return { accessToken: data.access_token, expiresAt };
}
export async function getGscAccessToken(projectId: string): Promise<string> {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
if (
conn.accessTokenExpiresAt &&
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
) {
return decrypt(conn.accessToken);
}
try {
const { accessToken, expiresAt } = await refreshGscToken(
decrypt(conn.refreshToken)
);
await db.gscConnection.update({
where: { projectId },
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
});
return accessToken;
} catch (error) {
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncStatus: 'token_expired',
lastSyncError:
error instanceof Error ? error.message : 'Failed to refresh token',
},
});
throw new Error(
'GSC token has expired or been revoked. Please reconnect Google Search Console.'
);
}
}
export async function listGscSites(projectId: string): Promise<GscSite[]> {
const accessToken = await getGscAccessToken(projectId);
const res = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to list GSC sites: ${text}`);
}
const data = (await res.json()) as {
siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>;
};
return data.siteEntry ?? [];
}
interface GscApiRow {
keys: string[];
clicks: number;
impressions: number;
ctr: number;
position: number;
}
interface GscDimensionFilter {
dimension: string;
operator: string;
expression: string;
}
interface GscFilterGroup {
filters: GscDimensionFilter[];
}
async function queryGscSearchAnalytics(
accessToken: string,
siteUrl: string,
startDate: string,
endDate: string,
dimensions: string[],
dimensionFilterGroups?: GscFilterGroup[]
): Promise<GscApiRow[]> {
const encodedSiteUrl = encodeURIComponent(siteUrl);
const url = `https://www.googleapis.com/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
const allRows: GscApiRow[] = [];
let startRow = 0;
const rowLimit = 25000;
while (true) {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate,
endDate,
dimensions,
rowLimit,
startRow,
dataState: 'all',
...(dimensionFilterGroups && { dimensionFilterGroups }),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`);
}
const data = (await res.json()) as { rows?: GscApiRow[] };
const rows = data.rows ?? [];
allRows.push(...rows);
if (rows.length < rowLimit) break;
startRow += rowLimit;
}
return allRows;
}
function formatDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
function nowString(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
export async function syncGscData(
projectId: string,
startDate: Date,
endDate: Date
): Promise<void> {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
if (!conn.siteUrl) {
throw new Error('No GSC site URL configured for this project');
}
const accessToken = await getGscAccessToken(projectId);
const start = formatDate(startDate);
const end = formatDate(endDate);
const syncedAt = nowString();
// 1. Daily totals — authoritative numbers for overview chart
const dailyRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date']
);
if (dailyRows.length > 0) {
await originalCh.insert({
table: 'gsc_daily',
values: dailyRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
// 2. Per-page breakdown
const pageRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date', 'page']
);
if (pageRows.length > 0) {
await originalCh.insert({
table: 'gsc_pages_daily',
values: pageRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
page: row.keys[1] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
// 3. Per-query breakdown
const queryRows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
start,
end,
['date', 'query']
);
if (queryRows.length > 0) {
await originalCh.insert({
table: 'gsc_queries_daily',
values: queryRows.map((row) => ({
project_id: projectId,
date: row.keys[0] ?? '',
query: row.keys[1] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
synced_at: syncedAt,
})),
format: 'JSONEachRow',
});
}
}
export async function getGscOverview(
projectId: string,
startDate: string,
endDate: string,
interval: 'day' | 'week' | 'month' = 'day'
): Promise<
Array<{
date: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const dateExpr =
interval === 'month'
? 'toStartOfMonth(date)'
: interval === 'week'
? 'toStartOfWeek(date)'
: 'date';
const result = await originalCh.query({
query: `
SELECT
${dateExpr} as date,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY date
ORDER BY date ASC
`,
query_params: { projectId, startDate, endDate },
format: 'JSONEachRow',
});
return result.json();
}
export async function getGscPages(
projectId: string,
startDate: string,
endDate: string,
limit = 100
): Promise<
Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const result = await originalCh.query({
query: `
SELECT
page,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_pages_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY page
ORDER BY clicks DESC
LIMIT {limit: UInt32}
`,
query_params: { projectId, startDate, endDate, limit },
format: 'JSONEachRow',
});
return result.json();
}
export interface GscCannibalizedQuery {
query: string;
totalImpressions: number;
totalClicks: number;
pages: Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
}
export const getGscCannibalization = cacheable(
async (
projectId: string,
startDate: string,
endDate: string
): Promise<GscCannibalizedQuery[]> => {
const conn = await db.gscConnection.findUniqueOrThrow({
where: { projectId },
});
const accessToken = await getGscAccessToken(projectId);
const rows = await queryGscSearchAnalytics(
accessToken,
conn.siteUrl,
startDate,
endDate,
['query', 'page']
);
const map = new Map<
string,
{
totalImpressions: number;
totalClicks: number;
pages: GscCannibalizedQuery['pages'];
}
>();
for (const row of rows) {
const query = row.keys[0] ?? '';
// Strip hash fragments — GSC records heading anchors (e.g. /page#section)
// as separate URLs but Google treats them as the same page
let page = row.keys[1] ?? '';
try {
const u = new URL(page);
u.hash = '';
page = u.toString();
} catch {
page = page.split('#')[0] ?? page;
}
const entry = map.get(query) ?? {
totalImpressions: 0,
totalClicks: 0,
pages: [],
};
entry.totalImpressions += row.impressions;
entry.totalClicks += row.clicks;
// Merge into existing page entry if already seen (from a different hash variant)
const existing = entry.pages.find((p) => p.page === page);
if (existing) {
const totalImpressions = existing.impressions + row.impressions;
if (totalImpressions > 0) {
existing.position =
(existing.position * existing.impressions + row.position * row.impressions) / totalImpressions;
}
existing.clicks += row.clicks;
existing.impressions += row.impressions;
existing.ctr =
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
} else {
entry.pages.push({
page,
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
});
}
map.set(query, entry);
}
return [...map.entries()]
.filter(([, v]) => v.pages.length >= 2 && v.totalImpressions >= 100)
.sort(([, a], [, b]) => b.totalImpressions - a.totalImpressions)
.slice(0, 50)
.map(([query, v]) => ({
query,
totalImpressions: v.totalImpressions,
totalClicks: v.totalClicks,
pages: v.pages.sort((a, b) =>
a.position !== b.position
? a.position - b.position
: b.impressions - a.impressions
),
}));
},
60 * 60 * 4
);
export async function getGscPageDetails(
projectId: string,
page: string,
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] }];
const [timeseriesRows, queryRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['query'], filterGroups),
]);
return {
timeseries: timeseriesRows.map((row) => ({
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
queries: queryRows.map((row) => ({
query: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
};
}
export async function getGscQueryDetails(
projectId: string,
query: string,
startDate: string,
endDate: string
): Promise<{
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>;
}> {
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
const accessToken = await getGscAccessToken(projectId);
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'query', operator: 'equals', expression: query }] }];
const [timeseriesRows, pageRows] = await Promise.all([
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['page'], filterGroups),
]);
return {
timeseries: timeseriesRows.map((row) => ({
date: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
pages: pageRows.map((row) => ({
page: row.keys[0] ?? '',
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
position: row.position,
})),
};
}
export async function getGscQueries(
projectId: string,
startDate: string,
endDate: string,
limit = 100
): Promise<
Array<{
query: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>
> {
const result = await originalCh.query({
query: `
SELECT
query,
sum(clicks) as clicks,
sum(impressions) as impressions,
avg(ctr) as ctr,
avg(position) as position
FROM gsc_queries_daily
FINAL
WHERE project_id = {projectId: String}
AND date >= {startDate: String}
AND date <= {endDate: String}
GROUP BY query
ORDER BY clicks DESC
LIMIT {limit: UInt32}
`,
query_params: { projectId, startDate, endDate, limit },
format: 'JSONEachRow',
});
return result.json();
}

View File

@@ -1,4 +1,5 @@
import { TABLE_NAMES, ch } from '../clickhouse/client';
import type { IInterval } from '@openpanel/validation';
import { ch, TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
export interface IGetPagesInput {
@@ -7,6 +8,15 @@ export interface IGetPagesInput {
endDate: string;
timezone: string;
search?: string;
limit?: number;
}
export interface IPageTimeseriesRow {
origin: string;
path: string;
date: string;
pageviews: number;
sessions: number;
}
export interface ITopPage {
@@ -28,6 +38,7 @@ export class PagesService {
endDate,
timezone,
search,
limit,
}: IGetPagesInput): Promise<ITopPage[]> {
// CTE: Get titles from the last 30 days for faster retrieval
const titlesCte = clix(this.client, timezone)
@@ -72,7 +83,7 @@ export class PagesService {
.leftJoin(
sessionsSubquery,
'e.session_id = s.id AND e.project_id = s.project_id',
's',
's'
)
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
.where('e.project_id', '=', projectId)
@@ -83,14 +94,69 @@ export class PagesService {
clix.datetime(endDate, 'toDateTime'),
])
.when(!!search, (q) => {
q.where('e.path', 'LIKE', `%${search}%`);
const term = `%${search}%`;
q.whereGroup()
.where('e.path', 'LIKE', term)
.orWhere('e.origin', 'LIKE', term)
.orWhere('pt.title', 'LIKE', term)
.end();
})
.groupBy(['e.origin', 'e.path', 'pt.title'])
.orderBy('sessions', 'DESC')
.limit(1000);
.orderBy('sessions', 'DESC');
if (limit !== undefined) {
query.limit(limit);
}
return query.execute();
}
async getPageTimeseries({
projectId,
startDate,
endDate,
timezone,
interval,
filterOrigin,
filterPath,
}: IGetPagesInput & {
interval: IInterval;
filterOrigin?: string;
filterPath?: string;
}): Promise<IPageTimeseriesRow[]> {
const dateExpr = clix.toStartOf('e.created_at', interval, timezone);
const useDateOnly = interval === 'month' || interval === 'week';
const fillFrom = clix.toStartOf(
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
interval
);
const fillTo = clix.datetime(
endDate,
useDateOnly ? 'toDate' : 'toDateTime'
);
const fillStep = clix.toInterval('1', interval);
return clix(this.client, timezone)
.select<IPageTimeseriesRow>([
'e.origin as origin',
'e.path as path',
`${dateExpr} AS date`,
'count() as pageviews',
'uniq(e.session_id) as sessions',
])
.from(`${TABLE_NAMES.events} e`, false)
.where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view')
.where('e.path', '!=', '')
.where('e.created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.when(!!filterOrigin, (q) => q.where('e.origin', '=', filterOrigin!))
.when(!!filterPath, (q) => q.where('e.path', '=', filterPath!))
.groupBy(['e.origin', 'e.path', 'date'])
.orderBy('date', 'ASC')
.fill(fillFrom, fillTo, fillStep)
.execute();
}
}
export const pagesService = new PagesService(ch);

View File

@@ -126,6 +126,10 @@ export type CronQueuePayloadFlushReplay = {
type: 'flushReplay';
payload: undefined;
};
export type CronQueuePayloadGscSync = {
type: 'gscSync';
payload: undefined;
};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -136,7 +140,8 @@ export type CronQueuePayload =
| CronQueuePayloadPing
| CronQueuePayloadProject
| CronQueuePayloadInsightsDaily
| CronQueuePayloadOnboarding;
| CronQueuePayloadOnboarding
| CronQueuePayloadGscSync;
export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon';
@@ -268,3 +273,21 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
},
}
);
export type GscQueuePayloadSync = {
type: 'gscProjectSync';
payload: { projectId: string };
};
export type GscQueuePayloadBackfill = {
type: 'gscProjectBackfill';
payload: { projectId: string };
};
export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill;
export const gscQueue = new Queue<GscQueuePayload>(getQueueName('gsc'), {
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 50,
removeOnFail: 100,
},
});

View File

@@ -1,4 +1,5 @@
import { authRouter } from './routers/auth';
import { gscRouter } from './routers/gsc';
import { chartRouter } from './routers/chart';
import { chatRouter } from './routers/chat';
import { clientRouter } from './routers/client';
@@ -53,6 +54,7 @@ export const appRouter = createTRPCRouter({
insight: insightRouter,
widget: widgetRouter,
email: emailRouter,
gsc: gscRouter,
});
// export type definition of API

View File

@@ -320,7 +320,7 @@ export const eventRouter = createTRPCRouter({
z.object({
projectId: z.string(),
cursor: z.number().optional(),
take: z.number().default(20),
take: z.number().min(1).optional(),
search: z.string().optional(),
range: zRange,
interval: zTimeInterval,
@@ -335,6 +335,80 @@ export const eventRouter = createTRPCRouter({
endDate,
timezone,
search: input.search,
limit: input.take,
});
}),
pagesTimeseries: protectedProcedure
.input(
z.object({
projectId: z.string(),
range: zRange,
interval: zTimeInterval,
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
return pagesService.getPageTimeseries({
projectId: input.projectId,
startDate,
endDate,
timezone,
interval: input.interval,
});
}),
previousPages: protectedProcedure
.input(
z.object({
projectId: z.string(),
range: zRange,
interval: zTimeInterval,
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
const startMs = new Date(startDate).getTime();
const endMs = new Date(endDate).getTime();
const duration = endMs - startMs;
const prevEnd = new Date(startMs - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
const fmt = (d: Date) =>
d.toISOString().slice(0, 19).replace('T', ' ');
return pagesService.getTopPages({
projectId: input.projectId,
startDate: fmt(prevStart),
endDate: fmt(prevEnd),
timezone,
});
}),
pageTimeseries: protectedProcedure
.input(
z.object({
projectId: z.string(),
range: zRange,
interval: zTimeInterval,
origin: z.string(),
path: z.string(),
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
return pagesService.getPageTimeseries({
projectId: input.projectId,
startDate,
endDate,
timezone,
interval: input.interval,
filterOrigin: input.origin,
filterPath: input.path,
});
}),

View File

@@ -0,0 +1,416 @@
import { Arctic, googleGsc } from '@openpanel/auth';
import {
chQuery,
db,
getChartStartEndDate,
getGscCannibalization,
getGscOverview,
getGscPageDetails,
getGscPages,
getGscQueries,
getGscQueryDetails,
getSettingsForProject,
listGscSites,
TABLE_NAMES,
} from '@openpanel/db';
import { gscQueue } from '@openpanel/queue';
import { zRange, zTimeInterval } from '@openpanel/validation';
import { z } from 'zod';
import { getProjectAccess } from '../access';
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
const zGscDateInput = z.object({
projectId: z.string(),
range: zRange,
interval: zTimeInterval.optional().default('day'),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
});
async function resolveDates(
projectId: string,
input: { range: string; startDate?: string | null; endDate?: string | null }
) {
const { timezone } = await getSettingsForProject(projectId);
const { startDate, endDate } = getChartStartEndDate(
{
range: input.range as any,
startDate: input.startDate,
endDate: input.endDate,
},
timezone
);
return {
startDate: startDate.slice(0, 10),
endDate: endDate.slice(0, 10),
};
}
export const gscRouter = createTRPCRouter({
getConnection: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
return db.gscConnection.findUnique({
where: { projectId: input.projectId },
select: {
id: true,
siteUrl: true,
lastSyncedAt: true,
lastSyncStatus: true,
lastSyncError: true,
backfillStatus: true,
createdAt: true,
updatedAt: true,
},
});
}),
initiateOAuth: protectedProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const state = Arctic.generateState();
const codeVerifier = Arctic.generateCodeVerifier();
const url = googleGsc.createAuthorizationURL(state, codeVerifier, [
'https://www.googleapis.com/auth/webmasters.readonly',
]);
url.searchParams.set('access_type', 'offline');
url.searchParams.set('prompt', 'consent');
const cookieOpts = { maxAge: 60 * 10, signed: true };
ctx.setCookie('gsc_oauth_state', state, cookieOpts);
ctx.setCookie('gsc_code_verifier', codeVerifier, cookieOpts);
ctx.setCookie('gsc_project_id', input.projectId, cookieOpts);
return { url: url.toString() };
}),
getSites: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
return listGscSites(input.projectId);
}),
selectSite: protectedProcedure
.input(z.object({ projectId: z.string(), siteUrl: z.string() }))
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const conn = await db.gscConnection.findUnique({
where: { projectId: input.projectId },
});
if (!conn) {
throw TRPCNotFoundError('GSC connection not found');
}
await db.gscConnection.update({
where: { projectId: input.projectId },
data: {
siteUrl: input.siteUrl,
backfillStatus: 'pending',
},
});
await gscQueue.add('gscProjectBackfill', {
type: 'gscProjectBackfill',
payload: { projectId: input.projectId },
});
return { ok: true };
}),
disconnect: protectedProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
await db.gscConnection.deleteMany({
where: { projectId: input.projectId },
});
return { ok: true };
}),
getOverview: protectedProcedure
.input(zGscDateInput)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
const interval = ['day', 'week', 'month'].includes(input.interval)
? (input.interval as 'day' | 'week' | 'month')
: 'day';
return getGscOverview(input.projectId, startDate, endDate, interval);
}),
getPages: protectedProcedure
.input(
zGscDateInput.extend({
limit: z.number().min(1).max(10_000).optional().default(100),
})
)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
return getGscPages(input.projectId, startDate, endDate, input.limit);
}),
getPageDetails: protectedProcedure
.input(zGscDateInput.extend({ page: z.string() }))
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
return getGscPageDetails(input.projectId, input.page, startDate, endDate);
}),
getQueryDetails: protectedProcedure
.input(zGscDateInput.extend({ query: z.string() }))
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
return getGscQueryDetails(
input.projectId,
input.query,
startDate,
endDate
);
}),
getQueries: protectedProcedure
.input(
zGscDateInput.extend({
limit: z.number().min(1).max(1000).optional().default(100),
})
)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
return getGscQueries(input.projectId, startDate, endDate, input.limit);
}),
getSearchEngines: protectedProcedure
.input(zGscDateInput)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
const startMs = new Date(startDate).getTime();
const duration = new Date(endDate).getTime() - startMs;
const prevEnd = new Date(startMs - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
const fmt = (d: Date) => d.toISOString().slice(0, 10);
const [engines, [prevResult]] = await Promise.all([
chQuery<{ name: string; sessions: number }>(
`SELECT
referrer_name as name,
count(*) as sessions
FROM ${TABLE_NAMES.sessions}
WHERE project_id = '${input.projectId}'
AND referrer_type = 'search'
AND created_at >= '${startDate}'
AND created_at <= '${endDate}'
GROUP BY referrer_name
ORDER BY sessions DESC
LIMIT 10`
),
chQuery<{ sessions: number }>(
`SELECT count(*) as sessions
FROM ${TABLE_NAMES.sessions}
WHERE project_id = '${input.projectId}'
AND referrer_type = 'search'
AND created_at >= '${fmt(prevStart)}'
AND created_at <= '${fmt(prevEnd)}'`
),
]);
return {
engines,
total: engines.reduce((s, e) => s + e.sessions, 0),
previousTotal: prevResult?.sessions ?? 0,
};
}),
getAiEngines: protectedProcedure
.input(zGscDateInput)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
const startMs = new Date(startDate).getTime();
const duration = new Date(endDate).getTime() - startMs;
const prevEnd = new Date(startMs - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
const fmt = (d: Date) => d.toISOString().slice(0, 10);
// Known AI referrer names — will switch to referrer_type = 'ai' once available
const aiNames = [
'chatgpt.com',
'openai.com',
'claude.ai',
'anthropic.com',
'perplexity.ai',
'gemini.google.com',
'copilot.com',
'grok.com',
'mistral.ai',
'kagi.com',
]
.map((n) => `'${n}', '${n.replace(/\.[^.]+$/, '')}'`)
.join(', ');
const where = (start: string, end: string) =>
`project_id = '${input.projectId}'
AND referrer_name IN (${aiNames})
AND created_at >= '${start}'
AND created_at <= '${end}'`;
const [engines, [prevResult]] = await Promise.all([
chQuery<{ referrer_name: string; sessions: number }>(
`SELECT lower(
regexp_replace(referrer_name, '^https?://', '')
) as referrer_name, count(*) as sessions
FROM ${TABLE_NAMES.sessions}
WHERE ${where(startDate, endDate)}
GROUP BY referrer_name
ORDER BY sessions DESC
LIMIT 10`
),
chQuery<{ sessions: number }>(
`SELECT count(*) as sessions
FROM ${TABLE_NAMES.sessions}
WHERE ${where(fmt(prevStart), fmt(prevEnd))}`
),
]);
return {
engines: engines.map((e) => ({
name: e.referrer_name,
sessions: e.sessions,
})),
total: engines.reduce((s, e) => s + e.sessions, 0),
previousTotal: prevResult?.sessions ?? 0,
};
}),
getPreviousOverview: protectedProcedure
.input(zGscDateInput)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
const startMs = new Date(startDate).getTime();
const duration = new Date(endDate).getTime() - startMs;
const prevEnd = new Date(startMs - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
const fmt = (d: Date) => d.toISOString().slice(0, 10);
const interval = (['day', 'week', 'month'] as const).includes(
input.interval as 'day' | 'week' | 'month'
)
? (input.interval as 'day' | 'week' | 'month')
: 'day';
return getGscOverview(
input.projectId,
fmt(prevStart),
fmt(prevEnd),
interval
);
}),
getCannibalization: protectedProcedure
.input(zGscDateInput)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const { startDate, endDate } = await resolveDates(input.projectId, input);
return getGscCannibalization(input.projectId, startDate, endDate);
}),
});

View File

@@ -37,6 +37,7 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
// @ts-ignore
res.setCookie(key, value, {
maxAge: options.maxAge,
signed: options.signed,
...COOKIE_OPTIONS,
});
};

View File

@@ -112,5 +112,6 @@ export type ISetCookie = (
sameSite?: 'lax' | 'strict' | 'none';
secure?: boolean;
httpOnly?: boolean;
signed?: boolean;
},
) => void;