diff --git a/apps/api/src/controllers/gsc-oauth-callback.controller.ts b/apps/api/src/controllers/gsc-oauth-callback.controller.ts new file mode 100644 index 000000000..ec9998f56 --- /dev/null +++ b/apps/api/src/controllers/gsc-oauth-callback.controller.ts @@ -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 | null | undefined +): Record { + if (!query || typeof query !== 'object') { + return {}; + } + return Object.fromEntries( + Object.entries(query).map(([k, v]) => [ + k, + OAUTH_SENSITIVE_KEYS.includes(k) ? '' : 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) + ); + } + + 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()); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bf933329a..cb5cdece3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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' }); }); diff --git a/apps/api/src/routes/gsc-callback.router.ts b/apps/api/src/routes/gsc-callback.router.ts new file mode 100644 index 000000000..6ac0491d3 --- /dev/null +++ b/apps/api/src/routes/gsc-callback.router.ts @@ -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; diff --git a/apps/start/src/components/chat/chat-report.tsx b/apps/start/src/components/chat/chat-report.tsx index bc967001f..745f674b3 100644 --- a/apps/start/src/components/chat/chat-report.tsx +++ b/apps/start/src/components/chat/chat-report.tsx @@ -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( - props.report.chartType, + props.report.chartType ); const [startDate, setStartDate] = useState(props.report.startDate); const [endDate, setEndDate] = useState(props.report.endDate); @@ -35,47 +38,48 @@ export function ChatReport({ }; return (
-
+
{props.report.name}
-
+
{ setChartType(type); }} + value={chartType} />
diff --git a/apps/start/src/components/overview/overview-range.tsx b/apps/start/src/components/overview/overview-range.tsx index d30c08647..ee1c64537 100644 --- a/apps/start/src/components/overview/overview-range.tsx +++ b/apps/start/src/components/overview/overview-range.tsx @@ -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 ( ); } diff --git a/apps/start/src/components/page/gsc-breakdown-table.tsx b/apps/start/src/components/page/gsc-breakdown-table.tsx new file mode 100644 index 000000000..09282c587 --- /dev/null +++ b/apps/start/src/components/page/gsc-breakdown-table.tsx @@ -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[] = + type === 'page' + ? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record[] + : ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record[]; + + 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 ( +
+
+

Top {pluralLabel}

+
+ {isLoading ? ( + String(i)} + getColumnPercentage={() => 0} + columns={[ + { name: breakdownLabel, width: 'w-full', render: () => }, + { name: 'Clicks', width: '70px', render: () => }, + { name: 'Impr.', width: '70px', render: () => }, + { name: 'CTR', width: '60px', render: () => }, + { name: 'Pos.', width: '55px', render: () => }, + ]} + /> + ) : ( + String(item[breakdownKey])} + getColumnPercentage={(item) => (item.clicks as number) / maxClicks} + columns={[ + { + name: breakdownLabel, + width: 'w-full', + render(item) { + return ( +
+ + {String(item[breakdownKey])} + +
+ ); + }, + }, + { + name: 'Clicks', + width: '70px', + getSortValue: (item) => item.clicks as number, + render(item) { + return ( + + {(item.clicks as number).toLocaleString()} + + ); + }, + }, + { + name: 'Impr.', + width: '70px', + getSortValue: (item) => item.impressions as number, + render(item) { + return ( + + {(item.impressions as number).toLocaleString()} + + ); + }, + }, + { + name: 'CTR', + width: '60px', + getSortValue: (item) => item.ctr as number, + render(item) { + return ( + + {((item.ctr as number) * 100).toFixed(1)}% + + ); + }, + }, + { + name: 'Pos.', + width: '55px', + getSortValue: (item) => item.position as number, + render(item) { + return ( + + {(item.position as number).toFixed(1)} + + ); + }, + }, + ]} + /> + )} +
+ ); +} diff --git a/apps/start/src/components/page/gsc-cannibalization.tsx b/apps/start/src/components/page/gsc-cannibalization.tsx new file mode 100644 index 000000000..ff20b8b5c --- /dev/null +++ b/apps/start/src/components/page/gsc-cannibalization.tsx @@ -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>(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 ( +
+
+
+

Keyword Cannibalization

+ {items.length > 0 && ( + + {items.length} + + )} +
+ {items.length > 0 && ( +
+ + {items.length === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${items.length}`} + + 0} + nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + pageIndex={page} + previousPage={() => setPage((p) => Math.max(0, p - 1))} + /> +
+ )} +
+
+ {query.isLoading && + [1, 2, 3].map((i) => ( +
+
+
+
+ ))} + {paginatedItems.map((item) => { + const isOpen = expanded.has(item.query); + const avgCtr = + item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length; + + return ( +
+ + + {isOpen && ( +
+

+ These pages all rank for{' '} + + "{item.query}" + + . Consider consolidating weaker pages into the top-ranking + one to concentrate link equity and avoid splitting clicks. +

+
+ {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 ( + + ); + })} +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/start/src/components/page/gsc-clicks-chart.tsx b/apps/start/src/components/page/gsc-clicks-chart.tsx new file mode 100644 index 000000000..6eb885d0c --- /dev/null +++ b/apps/start/src/components/page/gsc-clicks-chart.tsx @@ -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 ( + <> + +
{context.formatDate(item.date)}
+
+ +
+ Clicks + {item.clicks.toLocaleString()} +
+
+ +
+ Impressions + {item.impressions.toLocaleString()} +
+
+ + ); +}); + +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 ( +
+
+

Clicks & Impressions

+
+ + + Clicks + + + + Impressions + +
+
+ {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + formatDateShort(v)} + type="category" + /> + + + + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/gsc-ctr-benchmark.tsx b/apps/start/src/components/page/gsc-ctr-benchmark.tsx new file mode 100644 index 000000000..10847d847 --- /dev/null +++ b/apps/start/src/components/page/gsc-ctr-benchmark.tsx @@ -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 = { + 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 +>(({ data }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
Position #{item.position}
+
+ {item.yourCtr != null && ( + +
+ Your avg CTR + {item.yourCtr.toFixed(1)}% +
+
+ )} + +
+ Benchmark + {item.benchmark.toFixed(1)}% +
+
+ {item.pages.length > 0 && ( +
+ {item.pages.map((p) => ( +
+ + {p.path} + + + {(p.ctr * 100).toFixed(1)}% + +
+ ))} +
+ )} + + ); +}); + +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(); + 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 ( +
+
+

CTR vs Position

+
+ {hasAnyData && ( + + + Your CTR + + )} + + + Benchmark + +
+
+ {isLoading ? ( + + ) : ( + + + + + `#${v}`} + ticks={[1, 5, 10, 15, 20]} + type="number" + /> + `${v}%`} + /> + + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/gsc-position-chart.tsx b/apps/start/src/components/page/gsc-position-chart.tsx new file mode 100644 index 000000000..38b036e9d --- /dev/null +++ b/apps/start/src/components/page/gsc-position-chart.tsx @@ -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 +>(({ data }) => { + const item = data[0]; + if (!item) return null; + return ( + <> + +
{item.date}
+
+ +
+ Avg Position + {item.position.toFixed(1)} +
+
+ + ); +}); + +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 ( +
+
+

Avg Position

+ Lower is better +
+ {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + `#${v}`} + /> + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/page-views-chart.tsx b/apps/start/src/components/page/page-views-chart.tsx new file mode 100644 index 000000000..0f767a6ce --- /dev/null +++ b/apps/start/src/components/page/page-views-chart.tsx @@ -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 ( + <> + +
{context.formatDate(item.date)}
+
+ +
+ Views + {item.pageviews.toLocaleString()} +
+
+ +
+ Sessions + {item.sessions.toLocaleString()} +
+
+ + ); +}); + +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 ( +
+
+

Views & Sessions

+
+ + + Views + + + + Sessions + +
+
+ {query.isLoading ? ( + + ) : ( + + + + + + + + + + + + + + formatDateShort(v)} + type="category" + /> + + + + + + + + + )} +
+ ); +} diff --git a/apps/start/src/components/page/pages-insights.tsx b/apps/start/src/components/page/pages-insights.tsx new file mode 100644 index 000000000..c89c18326 --- /dev/null +++ b/apps/start/src/components/page/pages-insights.tsx @@ -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(() => { + 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(); + 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 ( +
+
+
+

Opportunities

+ {insights.length > 0 && ( + + {insights.length} + + )} +
+ {insights.length > 0 && ( +
+ + {insights.length === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${insights.length}`} + + 0} + nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + pageIndex={page} + previousPage={() => setPage((p) => Math.max(0, p - 1))} + /> +
+ )} +
+
+ {isLoading && + [1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+
+ ))} + {paginatedInsights.map((insight, i) => { + const config = INSIGHT_CONFIG[insight.type]; + const Icon = config.icon; + + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/start/src/components/pages/page-sparkline.tsx b/apps/start/src/components/pages/page-sparkline.tsx new file mode 100644 index 000000000..5ebf48d2e --- /dev/null +++ b/apps/start/src/components/pages/page-sparkline.tsx @@ -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
; + } + 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 ( +
+ + {data.map((d, i) => { + const barH = Math.max( + 2, + Math.round((d.pageviews / max) * (height * 0.8)) + ); + return ( + + ); + })} + + + + {trend} + + +
+ ); +} + +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 ( + }> + + + ); +} diff --git a/apps/start/src/components/pages/table/columns.tsx b/apps/start/src/components/pages/table/columns.tsx new file mode 100644 index 000000000..a1b1d41e0 --- /dev/null +++ b/apps/start/src/components/pages/table/columns.tsx @@ -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; +}): ColumnDef[] { + const number = useNumber(); + const { apiUrl } = useAppContext(); + + return useMemo[]>(() => { + const cols: ColumnDef[] = [ + { + 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 ( +
+ { + (e.target as HTMLImageElement).style.display = 'none'; + }} + src={`${apiUrl}/misc/favicon?url=${page.origin}`} + /> +
+ {page.title && ( +
+ {page.title} +
+ )} + +
+
+ ); + }, + }, + { + id: 'trend', + header: 'Trend', + enableSorting: false, + size: 96, + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'pageviews', + header: createHeaderColumn('Views'), + size: 80, + cell: ({ row }) => ( + + {number.short(row.original.pageviews)} + + ), + }, + { + accessorKey: 'sessions', + header: createHeaderColumn('Sessions'), + size: 90, + cell: ({ row }) => { + const prev = previousMap?.get( + row.original.origin + row.original.path + ); + if (prev == null) { + return ; + } + if (prev === 0) { + return ( +
+ + {number.short(row.original.sessions)} + + new +
+ ); + } + + const pct = ((row.original.sessions - prev) / prev) * 100; + const isPos = pct >= 0; + + return ( +
+ + {number.short(row.original.sessions)} + + + {isPos ? '+' : ''} + {pct.toFixed(1)}% + +
+ ); + }, + }, + { + accessorKey: 'bounce_rate', + header: createHeaderColumn('Bounce'), + size: 80, + cell: ({ row }) => ( + + {row.original.bounce_rate.toFixed(0)}% + + ), + }, + { + accessorKey: 'avg_duration', + header: createHeaderColumn('Duration'), + size: 90, + cell: ({ row }) => ( + + {fancyMinutes(row.original.avg_duration)} + + ), + }, + ]; + + if (isGscConnected) { + cols.push( + { + id: 'gsc_impressions', + accessorFn: (row) => row.gsc?.impressions ?? 0, + header: createHeaderColumn('Impr.'), + size: 80, + cell: ({ row }) => + row.original.gsc ? ( + + {number.short(row.original.gsc.impressions)} + + ) : ( + + ), + }, + { + id: 'gsc_ctr', + accessorFn: (row) => row.gsc?.ctr ?? 0, + header: createHeaderColumn('CTR'), + size: 70, + cell: ({ row }) => + row.original.gsc ? ( + + {(row.original.gsc.ctr * 100).toFixed(1)}% + + ) : ( + + ), + }, + { + id: 'gsc_clicks', + accessorFn: (row) => row.gsc?.clicks ?? 0, + header: createHeaderColumn('Clicks'), + size: 80, + cell: ({ row }) => + row.original.gsc ? ( + + {number.short(row.original.gsc.clicks)} + + ) : ( + + ), + } + ); + } + + return cols; + }, [isGscConnected, number, apiUrl, projectId, previousMap]); +} diff --git a/apps/start/src/components/pages/table/index.tsx b/apps/start/src/components/pages/table/index.tsx new file mode 100644 index 000000000..f09a8f64e --- /dev/null +++ b/apps/start/src/components/pages/table/index.tsx @@ -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(); + 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 ( + <> + + +
+ + + +
+
+ { + if (!isGscConnected) { + return; + } + const page = row.original; + pushModal('PageDetails', { + type: 'page', + projectId, + value: page.origin + page.path, + }); + }} + /> + + ); +} diff --git a/apps/start/src/components/report-chart/common/serie-icon.urls.ts b/apps/start/src/components/report-chart/common/serie-icon.urls.ts index 7e37d57fd..3542b033a 100644 --- a/apps/start/src/components/report-chart/common/serie-icon.urls.ts +++ b/apps/start/src/components/report-chart/common/serie-icon.urls.ts @@ -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", diff --git a/apps/start/src/components/report-chart/report-editor.tsx b/apps/start/src/components/report-chart/report-editor.tsx index dc1a04a98..af7bbeb16 100644 --- a/apps/start/src/components/report-chart/report-editor.tsx +++ b/apps/start/src/components/report-chart/report-editor.tsx @@ -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 (
-
+
{initialReport?.id && ( @@ -71,9 +69,9 @@ export default function ReportEditor({
@@ -88,23 +86,26 @@ export default function ReportEditor({ /> { 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} /> dispatch(changeInterval(newInterval))} range={report.range} - chartType={report.chartType} startDate={report.startDate} - endDate={report.endDate} />
@@ -114,7 +115,7 @@ export default function ReportEditor({
{report.ready && ( - + )}
diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 50498c891..097379627 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -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" /> + - +
Manage
diff --git a/apps/start/src/components/time-window-picker.tsx b/apps/start/src/components/time-window-picker.tsx index aed61ede3..e611e3e07 100644 --- a/apps/start/src/components/time-window-picker.tsx +++ b/apps/start/src/components/time-window-picker.tsx @@ -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({ diff --git a/apps/start/src/components/ui/calendar.tsx b/apps/start/src/components/ui/calendar.tsx index c3742167f..0673f170c 100644 --- a/apps/start/src/components/ui/calendar.tsx +++ b/apps/start/src/components/ui/calendar.tsx @@ -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 ( 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 (
); @@ -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(null); React.useEffect(() => { - if (modifiers.focused) ref.current?.focus(); + if (modifiers.focused) { + ref.current?.focus(); + } }, [modifiers.focused]); return ( {startDate && endDate && (
+ + ); +} + +function GscViewsChart({ + data, +}: { + data: Array<{ date: string; views: number }>; +}) { + const yAxisProps = useYAxisProps(); + + return ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + + + + + + + + ); +} + +function GscTimeseriesChart({ + data, +}: { + data: Array<{ date: string; clicks: number; impressions: number }>; +}) { + const yAxisProps = useYAxisProps(); + + return ( + + + + + + + + + + + + + + v.slice(5)} + type="category" + /> + + + + + + + + + ); +} diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index 63658f206..91c704247 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -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, diff --git a/apps/start/src/modals/page-details.tsx b/apps/start/src/modals/page-details.tsx new file mode 100644 index 000000000..5d4a571e8 --- /dev/null +++ b/apps/start/src/modals/page-details.tsx @@ -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 ( + + + + {value} + + + +
+ {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 ( + + ); + })()} + + +
+
+ ); +} diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 221cc39de..8742b5411 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -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, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx index 365121afc..0672797eb 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx @@ -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 ( - - - - - { - setSearch(e.target.value); - setCursor(1); - }} - /> - - {data.length === 0 && !isLoading && ( - - )} - {isLoading && ( -
- - - -
- )} -
- {data.map((page) => { - return ( - - ); - })} -
- {allData.length !== 0 && ( -
- 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)); - }} - /> -
- )} + +
); } - -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 ( -
-
-
- {page.title} -
-
- {page.title} -
- - {page.path} - -
-
-
-
-
-
- {number.formatWithUnit(page.avg_duration, 'min')} -
-
- duration -
-
-
-
- {number.formatWithUnit(page.bounce_rate / 100, '%')} -
-
- bounce rate -
-
-
-
- {number.format(page.sessions)} -
-
- sessions -
-
-
- -
- ); - }, -); - -const PageCardSkeleton = memo(() => { - return ( -
-
-
- -
- - -
-
-
-
-
- - -
-
- - -
-
- - -
-
-
- -
-
- ); -}); diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx new file mode 100644 index 000000000..1cb36b234 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx @@ -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 +>(({ data }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
{item.date}
+
+ +
+ Clicks + {item.clicks.toLocaleString()} +
+
+ +
+ Impressions + {item.impressions.toLocaleString()} +
+
+ + ); +}); + +function SeoPage() { + const { projectId, organizationId } = useAppParams(); + const trpc = useTRPC(); + const navigate = useNavigate(); + const { range, startDate, endDate, interval } = useOverviewOptions(); + + const dateInput = { + range, + interval, + startDate, + endDate, + }; + + const connectionQuery = useQuery( + trpc.gsc.getConnection.queryOptions({ projectId }) + ); + + const connection = connectionQuery.data; + const isConnected = connection?.siteUrl; + + const overviewQuery = useQuery( + trpc.gsc.getOverview.queryOptions( + { projectId, ...dateInput, interval: interval ?? 'day' }, + { enabled: !!isConnected } + ) + ); + + const pagesQuery = useQuery( + trpc.gsc.getPages.queryOptions( + { projectId, ...dateInput, limit: 50 }, + { enabled: !!isConnected } + ) + ); + + const queriesQuery = useQuery( + trpc.gsc.getQueries.queryOptions( + { projectId, ...dateInput, limit: 50 }, + { enabled: !!isConnected } + ) + ); + + const searchEnginesQuery = useQuery( + trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput }) + ); + + const aiEnginesQuery = useQuery( + trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput }) + ); + + const previousOverviewQuery = useQuery( + trpc.gsc.getPreviousOverview.queryOptions( + { projectId, ...dateInput, interval: interval ?? 'day' }, + { enabled: !!isConnected } + ) + ); + + const [pagesPage, setPagesPage] = useState(0); + const [queriesPage, setQueriesPage] = useState(0); + const pageSize = 15; + + const [pagesSearch, setPagesSearch] = useState(''); + const [queriesSearch, setQueriesSearch] = useState(''); + + const pages = pagesQuery.data ?? []; + const queries = queriesQuery.data ?? []; + + const filteredPages = useMemo(() => { + if (!pagesSearch.trim()) { + return pages; + } + const q = pagesSearch.toLowerCase(); + return pages.filter((row) => { + return String(row.page).toLowerCase().includes(q); + }); + }, [pages, pagesSearch]); + + const filteredQueries = useMemo(() => { + if (!queriesSearch.trim()) { + return queries; + } + const q = queriesSearch.toLowerCase(); + return queries.filter((row) => { + return String(row.query).toLowerCase().includes(q); + }); + }, [queries, queriesSearch]); + + const paginatedPages = useMemo( + () => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize), + [filteredPages, pagesPage, pageSize] + ); + + const paginatedQueries = useMemo( + () => + filteredQueries.slice( + queriesPage * pageSize, + (queriesPage + 1) * pageSize + ), + [filteredQueries, queriesPage, pageSize] + ); + + const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1; + const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1; + + if (connectionQuery.isLoading) { + return ( + + +
+ + +
+
+ ); + } + + if (!isConnected) { + return ( + + + + ); + } + + const overview = overviewQuery.data ?? []; + const prevOverview = previousOverviewQuery.data ?? []; + + const sumOverview = (rows: typeof overview) => + rows.reduce( + (acc, row) => ({ + clicks: acc.clicks + row.clicks, + impressions: acc.impressions + row.impressions, + ctr: acc.ctr + row.ctr, + position: acc.position + row.position, + }), + { clicks: 0, impressions: 0, ctr: 0, position: 0 } + ); + + const totals = sumOverview(overview); + const prevTotals = sumOverview(prevOverview); + const n = Math.max(overview.length, 1); + const pn = Math.max(prevOverview.length, 1); + + return ( + + + + + + } + description={`Search performance for ${connection.siteUrl}`} + title="SEO" + /> + +
+
+
+ ({ current: r.clicks, date: r.date }))} + id="clicks" + isLoading={overviewQuery.isLoading} + label="Clicks" + metric={{ current: totals.clicks, previous: prevTotals.clicks }} + /> + ({ + current: r.impressions, + date: r.date, + }))} + id="impressions" + isLoading={overviewQuery.isLoading} + label="Impressions" + metric={{ + current: totals.impressions, + previous: prevTotals.impressions, + }} + /> + ({ + current: r.ctr * 100, + date: r.date, + }))} + id="ctr" + isLoading={overviewQuery.isLoading} + label="Avg CTR" + metric={{ + current: (totals.ctr / n) * 100, + previous: (prevTotals.ctr / pn) * 100, + }} + unit="%" + /> + ({ + current: r.position, + date: r.date, + }))} + id="position" + inverted + isLoading={overviewQuery.isLoading} + label="Avg Position" + metric={{ + current: totals.position / n, + previous: prevTotals.position / pn, + }} + /> +
+ + +
+ + + +
+ + +
+ +
+ p.clicks), 1)} + onNextPage={() => + setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1)) + } + onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))} + onRowClick={(value) => + pushModal('PageDetails', { type: 'page', projectId, value }) + } + onSearchChange={(v) => { + setPagesSearch(v); + setPagesPage(0); + }} + pageCount={pagesPageCount} + pageIndex={pagesPage} + pageSize={pageSize} + rows={paginatedPages} + searchPlaceholder="Search pages" + searchValue={pagesSearch} + title="Top pages" + totalCount={filteredPages.length} + /> + q.clicks), 1)} + onNextPage={() => + setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1)) + } + onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))} + onRowClick={(value) => + pushModal('PageDetails', { type: 'query', projectId, value }) + } + onSearchChange={(v) => { + setQueriesSearch(v); + setQueriesPage(0); + }} + pageCount={queriesPageCount} + pageIndex={queriesPage} + pageSize={pageSize} + rows={paginatedQueries} + searchPlaceholder="Search queries" + searchValue={queriesSearch} + title="Top queries" + totalCount={filteredQueries.length} + /> +
+ +
+ + +
+
+
+ ); +} + +function TrafficSourceWidget({ + title, + engines, + total, + previousTotal, + isLoading, + emptyMessage, +}: { + title: string; + engines: Array<{ name: string; sessions: number }>; + total: number; + previousTotal: number; + isLoading: boolean; + emptyMessage: string; +}) { + const displayed = + engines.length > 8 + ? [ + ...engines.slice(0, 7), + { + name: 'Others', + sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0), + }, + ] + : engines.slice(0, 8); + + const max = displayed[0]?.sessions ?? 1; + const pctChange = + previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null; + + return ( +
+
+

{title}

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

+ {emptyMessage} +

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

Clicks & Impressions

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

{title}

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

{title}

+ {showPagination && ( +
+ + {totalCount === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${totalCount}`} + + +
+ )} +
+ {onSearchChange != null && ( +
+ + onSearchChange(e.target.value)} + placeholder={searchPlaceholder ?? 'Search'} + type="search" + value={searchValue ?? ''} + /> +
+ )} +
+ + +
+ ); + }, + }, + { + name: 'Clicks', + width: '70px', + getSortValue: (item) => item.clicks, + render(item) { + return ( + + {item.clicks.toLocaleString()} + + ); + }, + }, + { + name: 'Impr.', + width: '70px', + getSortValue: (item) => item.impressions, + render(item) { + return ( + + {item.impressions.toLocaleString()} + + ); + }, + }, + { + name: 'CTR', + width: '60px', + getSortValue: (item) => item.ctr, + render(item) { + return ( + + {(item.ctr * 100).toFixed(1)}% + + ); + }, + }, + { + name: 'Pos.', + width: '55px', + getSortValue: (item) => item.position, + render(item) { + return ( + + {item.position.toFixed(1)} + + ); + }, + }, + ]} + data={rows} + getColumnPercentage={(item) => item.clicks / maxClicks} + keyExtractor={(item) => String(item[keyField])} + /> +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx new file mode 100644 index 000000000..bd8f975ff --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx @@ -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 ( +
+ +
+ ); + } + + // Not connected at all + if (!connection) { + return ( +
+
+

Google Search Console

+

+ Connect your Google Search Console property to import search + performance data. +

+
+
+

+ You will be redirected to Google to authorize access. Only read-only + access to Search Console data is requested. +

+ +
+
+ ); + } + + // Connected but no site selected yet + if (!connection.siteUrl) { + const sites = sitesQuery.data ?? []; + return ( +
+
+

Select a property

+

+ Choose which Google Search Console property to connect to this + project. +

+
+
+ {sitesQuery.isLoading ? ( + + ) : sites.length === 0 ? ( +

+ No Search Console properties found for this Google account. +

+ ) : ( + <> + + + + )} +
+ +
+ ); + } + + // Token expired — show reconnect prompt + if (connection.lastSyncStatus === 'token_expired') { + return ( +
+
+

Google Search Console

+

+ Connected to Google Search Console. +

+
+
+
+ + Authorization expired +
+

+ Your Google Search Console authorization has expired or been + revoked. Please reconnect to continue syncing data. +

+ {connection.lastSyncError && ( +

+ {connection.lastSyncError} +

+ )} + +
+ +
+ ); + } + + // Fully connected + const syncStatusIcon = + connection.lastSyncStatus === 'success' ? ( + + ) : connection.lastSyncStatus === 'error' ? ( + + ) : null; + + const syncStatusVariant = + connection.lastSyncStatus === 'success' + ? 'success' + : connection.lastSyncStatus === 'error' + ? 'destructive' + : 'secondary'; + + return ( +
+
+

Google Search Console

+

+ Connected to Google Search Console. +

+
+ +
+
+
Property
+
+ {connection.siteUrl} +
+
+ + {connection.backfillStatus && ( +
+
Backfill
+ + {connection.backfillStatus === 'running' && ( + + )} + {connection.backfillStatus} + +
+ )} + + {connection.lastSyncedAt && ( +
+
Last synced
+
+ {connection.lastSyncStatus && ( + + {syncStatusIcon} + {connection.lastSyncStatus} + + )} + + {formatDistanceToNow(new Date(connection.lastSyncedAt), { + addSuffix: true, + })} + +
+
+ )} + + {connection.lastSyncError && ( +
+
+ Last error +
+
+ {connection.lastSyncError} +
+
+ )} +
+ + +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx index 205fb7bca..b0037ed9c 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx @@ -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) => { diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index 106cec07c..e3835e917 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -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') { diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts index 6d96dd61f..5b4285cad 100644 --- a/apps/worker/src/boot-workers.ts +++ b/apps/worker/src/boot-workers.ts @@ -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.', diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index 8d69afecb..f94286144 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -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) { case 'onboarding': { return await onboardingJob(job); } + case 'gscSync': { + return await gscSyncAllJob(); + } } } diff --git a/apps/worker/src/jobs/gsc.ts b/apps/worker/src/jobs/gsc.ts new file mode 100644 index 000000000..6ed9f51af --- /dev/null +++ b/apps/worker/src/jobs/gsc.ts @@ -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) { + 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 }, + }); + } +} diff --git a/packages/auth/server/oauth.ts b/packages/auth/server/oauth.ts deleted file mode 100644 index 18464f6a6..000000000 --- a/packages/auth/server/oauth.ts +++ /dev/null @@ -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 ?? '', -); diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts index 18464f6a6..09f17adec 100644 --- a/packages/auth/src/oauth.ts +++ b/packages/auth/src/oauth.ts @@ -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 ?? '' ); diff --git a/packages/db/code-migrations/12-add-gsc.ts b/packages/db/code-migrations/12-add-gsc.ts new file mode 100644 index 000000000..43d82d9fc --- /dev/null +++ b/packages/db/code-migrations/12-add-gsc.ts @@ -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); + } +} diff --git a/packages/db/index.ts b/packages/db/index.ts index f0e461c3b..b71c3d3ab 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -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'; diff --git a/packages/db/prisma/migrations/20260306133001_gsc/migration.sql b/packages/db/prisma/migrations/20260306133001_gsc/migration.sql new file mode 100644 index 000000000..ff5664bc8 --- /dev/null +++ b/packages/db/prisma/migrations/20260306133001_gsc/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 784f94fb0..8bf72e143 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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 diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index d2899f825..3a0ba20ab 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -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', }; /** diff --git a/packages/db/src/encryption.ts b/packages/db/src/encryption.ts new file mode 100644 index 000000000..b0835ec06 --- /dev/null +++ b/packages/db/src/encryption.ts @@ -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'); +} diff --git a/packages/db/src/gsc.ts b/packages/db/src/gsc.ts new file mode 100644 index 000000000..b7ca8c8de --- /dev/null +++ b/packages/db/src/gsc.ts @@ -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 { + 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 { + 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 { + 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 { + 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 => { + 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(); +} diff --git a/packages/db/src/services/pages.service.ts b/packages/db/src/services/pages.service.ts index 8e2fdd975..b014c4166 100644 --- a/packages/db/src/services/pages.service.ts +++ b/packages/db/src/services/pages.service.ts @@ -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 { // 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 { + 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([ + '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); diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 4ba92b6a7..aa769b486 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -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( }, } ); + +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(getQueueName('gsc'), { + connection: getRedisQueue(), + defaultJobOptions: { + removeOnComplete: 50, + removeOnFail: 100, + }, +}); diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 068a321dd..626c13125 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -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 diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 60e7cde09..385de6b12 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -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, }); }), diff --git a/packages/trpc/src/routers/gsc.ts b/packages/trpc/src/routers/gsc.ts new file mode 100644 index 000000000..743340c43 --- /dev/null +++ b/packages/trpc/src/routers/gsc.ts @@ -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); + }), +}); diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index e40997f2e..20835bd9f 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -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, }); }; diff --git a/packages/validation/src/types.validation.ts b/packages/validation/src/types.validation.ts index 920e10daa..bccedcbc2 100644 --- a/packages/validation/src/types.validation.ts +++ b/packages/validation/src/types.validation.ts @@ -112,5 +112,6 @@ export type ISetCookie = ( sameSite?: 'lax' | 'strict' | 'none'; secure?: boolean; httpOnly?: boolean; + signed?: boolean; }, ) => void;