From c9cf7901adf9a5c0106a0a80513daae6411b1f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 9 Mar 2026 12:30:28 +0100 Subject: [PATCH] wip --- .../gsc-oauth-callback.controller.ts | 34 +- apps/api/src/index.ts | 2 +- apps/api/src/routes/gsc-callback.router.ts | 7 +- .../components/page/gsc-breakdown-table.tsx | 142 +++ .../components/page/gsc-cannibalization.tsx | 217 ++++ .../src/components/page/gsc-clicks-chart.tsx | 197 ++++ .../src/components/page/gsc-ctr-benchmark.tsx | 228 +++++ .../components/page/gsc-position-chart.tsx | 129 +++ .../src/components/page/page-views-chart.tsx | 180 ++++ .../src/components/page/pages-insights.tsx | 332 +++++++ .../src/components/pages/page-sparkline.tsx | 113 +++ .../src/components/pages/table/columns.tsx | 199 ++++ .../src/components/pages/table/index.tsx | 147 +++ .../report-chart/common/serie-icon.urls.ts | 1 + .../src/components/sidebar-project-menu.tsx | 5 +- .../components/ui/data-table/data-table.tsx | 4 + .../src/hooks/use-format-date-interval.ts | 29 +- apps/start/src/modals/gsc-details.tsx | 392 ++++++++ apps/start/src/modals/index.tsx | 2 + apps/start/src/modals/page-details.tsx | 46 + .../_app.$organizationId.$projectId.pages.tsx | 339 +------ .../_app.$organizationId.$projectId.seo.tsx | 939 ++++++++++++++---- ...zationId.$projectId.settings._tabs.gsc.tsx | 164 ++- packages/auth/server/oauth.ts | 18 - packages/auth/src/oauth.ts | 7 +- packages/db/index.ts | 1 + .../20260306133001_gsc/migration.sql | 23 + packages/db/src/encryption.ts | 44 + packages/db/src/gsc.ts | 232 ++++- packages/db/src/services/pages.service.ts | 62 +- packages/trpc/src/routers/event.ts | 73 ++ packages/trpc/src/routers/gsc.ts | 277 +++++- 32 files changed, 3908 insertions(+), 677 deletions(-) create mode 100644 apps/start/src/components/page/gsc-breakdown-table.tsx create mode 100644 apps/start/src/components/page/gsc-cannibalization.tsx create mode 100644 apps/start/src/components/page/gsc-clicks-chart.tsx create mode 100644 apps/start/src/components/page/gsc-ctr-benchmark.tsx create mode 100644 apps/start/src/components/page/gsc-position-chart.tsx create mode 100644 apps/start/src/components/page/page-views-chart.tsx create mode 100644 apps/start/src/components/page/pages-insights.tsx create mode 100644 apps/start/src/components/pages/page-sparkline.tsx create mode 100644 apps/start/src/components/pages/table/columns.tsx create mode 100644 apps/start/src/components/pages/table/index.tsx create mode 100644 apps/start/src/modals/gsc-details.tsx create mode 100644 apps/start/src/modals/page-details.tsx delete mode 100644 packages/auth/server/oauth.ts create mode 100644 packages/db/prisma/migrations/20260306133001_gsc/migration.sql create mode 100644 packages/db/src/encryption.ts diff --git a/apps/api/src/controllers/gsc-oauth-callback.controller.ts b/apps/api/src/controllers/gsc-oauth-callback.controller.ts index 639fc50f..fd5975e3 100644 --- a/apps/api/src/controllers/gsc-oauth-callback.controller.ts +++ b/apps/api/src/controllers/gsc-oauth-callback.controller.ts @@ -1,31 +1,9 @@ -import { COOKIE_OPTIONS, googleGsc } from '@openpanel/auth'; -import { db } from '@openpanel/db'; +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'; -export async function gscInitiate(req: FastifyRequest, reply: FastifyReply) { - const schema = z.object({ - state: z.string(), - code_verifier: z.string(), - project_id: z.string(), - redirect: z.string().url(), - }); - - const query = schema.safeParse(req.query); - if (!query.success) { - return reply.status(400).send({ error: 'Invalid parameters' }); - } - - const { state, code_verifier, project_id, redirect } = query.data; - - reply.setCookie('gsc_oauth_state', state, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); - reply.setCookie('gsc_code_verifier', code_verifier, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); - reply.setCookie('gsc_project_id', project_id, { maxAge: 60 * 10, ...COOKIE_OPTIONS }); - - return reply.redirect(redirect); -} - export async function gscGoogleCallback( req: FastifyRequest, reply: FastifyReply @@ -89,14 +67,14 @@ export async function gscGoogleCallback( where: { projectId }, create: { projectId, - accessToken, - refreshToken, + accessToken: encrypt(accessToken), + refreshToken: encrypt(refreshToken), accessTokenExpiresAt, siteUrl: '', }, update: { - accessToken, - refreshToken, + accessToken: encrypt(accessToken), + refreshToken: encrypt(refreshToken), accessTokenExpiresAt, lastSyncStatus: null, lastSyncError: null, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ee5e0fd7..cb5cdece 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -36,13 +36,13 @@ 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'; import manageRouter from './routes/manage.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; -import gscCallbackRouter from './routes/gsc-callback.router'; import profileRouter from './routes/profile.router'; import trackRouter from './routes/track.router'; import webhookRouter from './routes/webhook.router'; diff --git a/apps/api/src/routes/gsc-callback.router.ts b/apps/api/src/routes/gsc-callback.router.ts index 0becfb77..6ac0491d 100644 --- a/apps/api/src/routes/gsc-callback.router.ts +++ b/apps/api/src/routes/gsc-callback.router.ts @@ -1,12 +1,7 @@ -import { gscGoogleCallback, gscInitiate } from '@/controllers/gsc-oauth-callback.controller'; +import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller'; import type { FastifyPluginCallback } from 'fastify'; const router: FastifyPluginCallback = async (fastify) => { - fastify.route({ - method: 'GET', - url: '/initiate', - handler: gscInitiate, - }); fastify.route({ method: 'GET', url: '/callback', 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 00000000..51298787 --- /dev/null +++ b/apps/start/src/components/page/gsc-breakdown-table.tsx @@ -0,0 +1,142 @@ +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 maxClicks = Math.max( + ...(breakdownRows as { clicks: number }[]).map((r) => r.clicks), + 1, + ); + + return ( +
+
+

Top {breakdownLabel.toLowerCase()}s

+
+ {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 00000000..bf0f2dec --- /dev/null +++ b/apps/start/src/components/page/gsc-cannibalization.tsx @@ -0,0 +1,217 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react'; +import { 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: string; + interval: string; + 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: range as any, interval: interval as any }, + { 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; + 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 winner = item.pages[0]; + 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 00000000..6eb885d0 --- /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 00000000..10847d84 --- /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 00000000..38b036e9 --- /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 00000000..0f767a6c --- /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 00000000..c89c1832 --- /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 00000000..a724ec1d --- /dev/null +++ b/apps/start/src/components/pages/page-sparkline.tsx @@ -0,0 +1,113 @@ +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 gap = 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; + const barW = Math.max(2, Math.floor((width - gap * (total - 1)) / total)); + 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 00000000..ba3b433e --- /dev/null +++ b/apps/start/src/components/pages/table/columns.tsx @@ -0,0 +1,199 @@ +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 ; + } + + const pct = ((row.original.sessions - prev) / prev) * 100; + const isPos = pct >= 0; + + return ( +
+ + {number.short(row.original.sessions)} + + {prev === 0 && new} + {prev > 0 && ( + + {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 00000000..8d798bf8 --- /dev/null +++ b/apps/start/src/components/pages/table/index.tsx @@ -0,0 +1,147 @@ +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, cursor: 1, take: 1000, search: 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: 1000, + }, + { 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 filteredData = useMemo(() => { + if (!debouncedSearch) return rawData; + const q = debouncedSearch.toLowerCase(); + return rawData.filter( + (p) => + p.path.toLowerCase().includes(q) || + p.origin.toLowerCase().includes(q) || + (p.title ?? '').toLowerCase().includes(q), + ); + }, [rawData, debouncedSearch]); + + const columns = useColumns({ projectId, isGscConnected, previousMap }); + + const { table } = useTable({ + columns, + data: filteredData, + 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 7e37d57f..3542b033 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/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 8c0a5594..09737962 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -18,6 +18,7 @@ import { SparklesIcon, TrendingUpDownIcon, UndoDotIcon, + UserCircleIcon, UsersIcon, WallpaperIcon, } from 'lucide-react'; @@ -56,11 +57,11 @@ export default function SidebarProjectMenu({ label="Insights" /> + - - +
Manage
diff --git a/apps/start/src/components/ui/data-table/data-table.tsx b/apps/start/src/components/ui/data-table/data-table.tsx index 6cf1e7f5..52f35ab3 100644 --- a/apps/start/src/components/ui/data-table/data-table.tsx +++ b/apps/start/src/components/ui/data-table/data-table.tsx @@ -22,6 +22,7 @@ export interface DataTableProps { title: string; description: string; }; + onRowClick?: (row: import('@tanstack/react-table').Row) => void; } declare module '@tanstack/react-table' { @@ -35,6 +36,7 @@ export function DataTable({ table, loading, className, + onRowClick, empty = { title: 'No data', description: 'We could not find any data here yet', @@ -78,6 +80,8 @@ export function DataTable({ onRowClick(row) : undefined} + className={onRowClick ? 'cursor-pointer' : undefined} > {row.getVisibleCells().map((cell) => ( +>(({ data }) => { + const item = data[0]; + if (!item) { + return null; + } + return ( + <> + +
{item.date}
+
+ +
+ Clicks + {item.clicks.toLocaleString()} +
+
+ +
+ Impressions + {item.impressions.toLocaleString()} +
+
+ + ); +}); + +type Props = + | { + type: 'page'; + projectId: string; + value: string; + range: IChartRange; + interval: IInterval; + } + | { + type: 'query'; + projectId: string; + value: string; + range: IChartRange; + interval: IInterval; + }; + +export default function GscDetails(props: Props) { + const { type, projectId, value, range, interval } = props; + const trpc = useTRPC(); + + const dateInput = { + range, + interval, + }; + + const pageQuery = useQuery( + trpc.gsc.getPageDetails.queryOptions( + { projectId, page: value, ...dateInput }, + { enabled: type === 'page' } + ) + ); + + const queryQuery = useQuery( + trpc.gsc.getQueryDetails.queryOptions( + { projectId, query: value, ...dateInput }, + { enabled: type === 'query' } + ) + ); + + const pagesTimeseriesQuery = useQuery( + trpc.event.pagesTimeseries.queryOptions( + { projectId, ...dateInput }, + { enabled: type === 'page' } + ) + ); + + const data = type === 'page' ? pageQuery.data : queryQuery.data; + const isLoading = + type === 'page' ? pageQuery.isLoading : queryQuery.isLoading; + + const timeseries = data?.timeseries ?? []; + const pagesTimeseries = pagesTimeseriesQuery.data ?? []; + const breakdownRows = + type === 'page' + ? ((data as { queries?: unknown[] } | undefined)?.queries ?? []) + : ((data as { pages?: unknown[] } | undefined)?.pages ?? []); + + const breakdownKey = type === 'page' ? 'query' : 'page'; + const breakdownLabel = type === 'page' ? 'Query' : 'Page'; + + const maxClicks = Math.max( + ...(breakdownRows as { clicks: number }[]).map((r) => r.clicks), + 1 + ); + + return ( + + + + {value} + + + +
+ {type === 'page' && ( +
+

Views & Sessions

+ {isLoading ? ( + + ) : ( + r.origin + r.path === value) + .map((r) => ({ date: r.date, views: r.pageviews }))} + /> + )} +
+ )} + +
+

Clicks & Impressions

+ {isLoading ? ( + + ) : ( + + )} +
+ +
+
+

+ Top {breakdownLabel.toLowerCase()}s +

+
+ {isLoading ? ( + , + }, + { + name: 'Clicks', + width: '70px', + render: () => , + }, + { + name: 'Pos.', + width: '55px', + render: () => , + }, + ]} + data={[1, 2, 3, 4, 5]} + getColumnPercentage={() => 0} + keyExtractor={(i) => String(i)} + /> + ) : ( + + + {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)} + + ); + }, + }, + ]} + data={breakdownRows as Record[]} + getColumnPercentage={(item) => + (item.clicks as number) / maxClicks + } + keyExtractor={(item) => String(item[breakdownKey])} + /> + )} +
+
+ + ); +} + +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 63658f20..91c70424 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 00000000..8c29f579 --- /dev/null +++ b/apps/start/src/modals/page-details.tsx @@ -0,0 +1,46 @@ +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 = value; + let path = '/'; + try { + const url = new URL(value); + origin = url.origin; + path = url.pathname + url.search; + } catch { + // value might already be just a path + } + return ( + + ); + })()} + + +
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx index 365121af..0672797e 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 index ba87e5f9..79e98846 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx @@ -1,81 +1,217 @@ -import { PageContainer } from '@/components/page-container'; -import { PageHeader } from '@/components/page-header'; -import { Skeleton } from '@/components/skeleton'; -import { Button } from '@/components/ui/button'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { useAppParams } from '@/hooks/use-app-params'; -import { useTRPC } from '@/integrations/trpc/react'; -import { createProjectTitle } from '@/utils/title'; + getDefaultIntervalByRange, + intervals, + timeWindows, +} from '@openpanel/constants'; +import type { IChartRange, IInterval } from '@openpanel/validation'; import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { subDays, format } from 'date-fns'; +import { SearchIcon } from 'lucide-react'; +import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'; +import { useMemo, useState } from 'react'; import { - Area, - AreaChart, CartesianGrid, + ComposedChart, + Line, ResponsiveContainer, - Tooltip, XAxis, YAxis, } from 'recharts'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; +import { OverviewWidgetTable } from '@/components/overview/overview-widget-table'; +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 { ReportInterval } from '@/components/report/ReportInterval'; +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 { TimeWindowPicker } from '@/components/time-window-picker'; +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' -)({ +export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({ component: SeoPage, head: () => ({ meta: [{ title: createProjectTitle('SEO') }], }), }); -const startDate = format(subDays(new Date(), 30), 'yyyy-MM-dd'); -const endDate = format(subDays(new Date(), 1), 'yyyy-MM-dd'); +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, setRange] = useQueryState( + 'range', + parseAsStringEnum(Object.keys(timeWindows) as IChartRange[]).withDefault( + '30d' as IChartRange + ) + ); + const [startDate, setStartDate] = useQueryState('start', parseAsString); + const [endDate, setEndDate] = useQueryState('end', parseAsString); + const [interval, setInterval] = useQueryState( + 'interval', + parseAsStringEnum(Object.keys(intervals) as IInterval[]).withDefault( + (getDefaultIntervalByRange(range) ?? 'day') as IInterval + ) + ); + + const dateInput = { + range, + interval, + startDate: startDate ?? undefined, + endDate: endDate ?? undefined, + }; + const connectionQuery = useQuery( trpc.gsc.getConnection.queryOptions({ projectId }) ); const connection = connectionQuery.data; - const isConnected = connection && connection.siteUrl; + const isConnected = connection?.siteUrl; const overviewQuery = useQuery( trpc.gsc.getOverview.queryOptions( - { projectId, startDate, endDate }, + { projectId, ...dateInput, interval: interval ?? 'day' }, { enabled: !!isConnected } ) ); const pagesQuery = useQuery( trpc.gsc.getPages.queryOptions( - { projectId, startDate, endDate, limit: 50 }, + { projectId, ...dateInput, limit: 50 }, { enabled: !!isConnected } ) ); const queriesQuery = useQuery( trpc.gsc.getQueries.queryOptions( - { projectId, startDate, endDate, limit: 50 }, + { 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 ( - -
+ +
@@ -85,138 +221,438 @@ function SeoPage() { if (!isConnected) { return ( - - -
-
- - - -
-

No SEO data yet

-

- Connect Google Search Console to track your search impressions, clicks, and keyword rankings. -

- -
-
+ + + ); } const overview = overviewQuery.data ?? []; - const pages = pagesQuery.data ?? []; - const queries = queriesQuery.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 ( + setInterval(v)} + range={range} + startDate={startDate} + /> + { + if (v !== 'custom') { + setStartDate(null); + setEndDate(null); + } + setInterval( + (getDefaultIntervalByRange(v) ?? 'day') as IInterval + ); + setRange(v); + }} + onEndDateChange={setEndDate} + onStartDateChange={setStartDate} + startDate={startDate} + value={range} + /> + + } description={`Search performance for ${connection.siteUrl}`} + title="SEO" />
- {/* Summary metrics */} -
- {(['clicks', 'impressions', 'ctr', 'position'] as const).map((metric) => { - const total = overview.reduce((sum, row) => { - if (metric === 'ctr' || metric === 'position') { - return sum + row[metric]; - } - return sum + row[metric]; - }, 0); - const display = - metric === 'ctr' - ? `${((total / Math.max(overview.length, 1)) * 100).toFixed(1)}%` - : metric === 'position' - ? (total / Math.max(overview.length, 1)).toFixed(1) - : total.toLocaleString(); - const label = - metric === 'ctr' - ? 'Avg CTR' - : metric === 'position' - ? 'Avg Position' - : metric.charAt(0).toUpperCase() + metric.slice(1); - - return ( -
-
{label}
-
{overviewQuery.isLoading ? : display}
-
- ); - })} +
+
+ ({ 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, + }} + /> +
+ +
- {/* Clicks over time chart */} -
-

Clicks over time

- {overviewQuery.isLoading ? ( - - ) : ( - - - - - - - - - - - - - - - - )} -
+ - {/* Pages and Queries tables */} -
- + + +
+ +
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; @@ -228,62 +664,201 @@ interface GscTableRow { function GscTable({ title, rows, - keyLabel, keyField, + keyLabel, + maxClicks, isLoading, + onRowClick, + searchValue, + onSearchChange, + searchPlaceholder, + totalCount, + pageIndex, + pageSize, + pageCount, + onPreviousPage, + onNextPage, }: { title: string; rows: GscTableRow[]; - keyLabel: string; 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; }) { - return ( -
-
-

{title}

+ 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)} + />
- - - - {keyLabel} - Clicks - Impressions - CTR - Position - - - - {isLoading && - Array.from({ length: 5 }).map((_, i) => ( - - {Array.from({ length: 5 }).map((_, j) => ( - - - - ))} - - ))} - {!isLoading && rows.length === 0 && ( - - - No data yet - - + ); + } + + return ( +
+
+
+

{title}

+ {showPagination && ( +
+ + {totalCount === 0 + ? '0 results' + : `${rangeStart}-${rangeEnd} of ${totalCount}`} + + +
)} - {rows.map((row) => ( - - - {String(row[keyField])} - - {row.clicks.toLocaleString()} - {row.impressions.toLocaleString()} - {(row.ctr * 100).toFixed(1)}% - {row.position.toFixed(1)} - - ))} - -
+
+ {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 index af18c356..bd8f975f 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx @@ -1,3 +1,9 @@ +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'; @@ -10,12 +16,6 @@ import { } from '@/components/ui/select'; import { useAppParams } from '@/hooks/use-app-params'; import { useTRPC } from '@/integrations/trpc/react'; -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'; export const Route = createFileRoute( '/_app/$organizationId/$projectId/settings/_tabs/gsc' @@ -46,14 +46,7 @@ function GscSettings() { const initiateOAuth = useMutation( trpc.gsc.initiateOAuth.mutationOptions({ onSuccess: (data) => { - // Route through the API /gsc/initiate endpoint which sets cookies then redirects to Google - const apiUrl = (import.meta.env.VITE_API_URL as string) ?? ''; - const initiateUrl = new URL(`${apiUrl}/gsc/initiate`); - initiateUrl.searchParams.set('state', data.state); - initiateUrl.searchParams.set('code_verifier', data.codeVerifier); - initiateUrl.searchParams.set('project_id', data.projectId); - initiateUrl.searchParams.set('redirect', data.url); - window.location.href = initiateUrl.toString(); + window.location.href = data.url; }, onError: () => { toast.error('Failed to initiate Google Search Console connection'); @@ -102,19 +95,21 @@ function GscSettings() { return (
-

Google Search Console

-

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

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. +

+

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

@@ -181,6 +179,56 @@ function GscSettings() { ); } + // 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' ? ( @@ -199,28 +247,35 @@ function GscSettings() { return (
-

Google Search Console

-

+

Google Search Console

+

Connected to Google Search Console.

-
-
-
Property
-
+
+
+
Property
+
{connection.siteUrl}
{connection.backfillStatus && ( -
-
Backfill
- +
+
Backfill
+ {connection.backfillStatus === 'running' && ( )} @@ -230,16 +285,19 @@ function GscSettings() { )} {connection.lastSyncedAt && ( -
-
Last synced
+
+
Last synced
{connection.lastSyncStatus && ( - + {syncStatusIcon} {connection.lastSyncStatus} )} - + {formatDistanceToNow(new Date(connection.lastSyncedAt), { addSuffix: true, })} @@ -250,8 +308,10 @@ function GscSettings() { {connection.lastSyncError && (
-
Last error
-
+
+ Last error +
+
{connection.lastSyncError}
@@ -259,10 +319,10 @@ function GscSettings() {