wip
This commit is contained in:
@@ -1,31 +1,9 @@
|
|||||||
import { COOKIE_OPTIONS, googleGsc } from '@openpanel/auth';
|
import { googleGsc } from '@openpanel/auth';
|
||||||
import { db } from '@openpanel/db';
|
import { db, encrypt } from '@openpanel/db';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { LogError } from '@/utils/errors';
|
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(
|
export async function gscGoogleCallback(
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
@@ -89,14 +67,14 @@ export async function gscGoogleCallback(
|
|||||||
where: { projectId },
|
where: { projectId },
|
||||||
create: {
|
create: {
|
||||||
projectId,
|
projectId,
|
||||||
accessToken,
|
accessToken: encrypt(accessToken),
|
||||||
refreshToken,
|
refreshToken: encrypt(refreshToken),
|
||||||
accessTokenExpiresAt,
|
accessTokenExpiresAt,
|
||||||
siteUrl: '',
|
siteUrl: '',
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
accessToken,
|
accessToken: encrypt(accessToken),
|
||||||
refreshToken,
|
refreshToken: encrypt(refreshToken),
|
||||||
accessTokenExpiresAt,
|
accessTokenExpiresAt,
|
||||||
lastSyncStatus: null,
|
lastSyncStatus: null,
|
||||||
lastSyncError: null,
|
lastSyncError: null,
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ import { timestampHook } from './hooks/timestamp.hook';
|
|||||||
import aiRouter from './routes/ai.router';
|
import aiRouter from './routes/ai.router';
|
||||||
import eventRouter from './routes/event.router';
|
import eventRouter from './routes/event.router';
|
||||||
import exportRouter from './routes/export.router';
|
import exportRouter from './routes/export.router';
|
||||||
|
import gscCallbackRouter from './routes/gsc-callback.router';
|
||||||
import importRouter from './routes/import.router';
|
import importRouter from './routes/import.router';
|
||||||
import insightsRouter from './routes/insights.router';
|
import insightsRouter from './routes/insights.router';
|
||||||
import liveRouter from './routes/live.router';
|
import liveRouter from './routes/live.router';
|
||||||
import manageRouter from './routes/manage.router';
|
import manageRouter from './routes/manage.router';
|
||||||
import miscRouter from './routes/misc.router';
|
import miscRouter from './routes/misc.router';
|
||||||
import oauthRouter from './routes/oauth-callback.router';
|
import oauthRouter from './routes/oauth-callback.router';
|
||||||
import gscCallbackRouter from './routes/gsc-callback.router';
|
|
||||||
import profileRouter from './routes/profile.router';
|
import profileRouter from './routes/profile.router';
|
||||||
import trackRouter from './routes/track.router';
|
import trackRouter from './routes/track.router';
|
||||||
import webhookRouter from './routes/webhook.router';
|
import webhookRouter from './routes/webhook.router';
|
||||||
|
|||||||
@@ -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';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
const router: FastifyPluginCallback = async (fastify) => {
|
const router: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/initiate',
|
|
||||||
handler: gscInitiate,
|
|
||||||
});
|
|
||||||
fastify.route({
|
fastify.route({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/callback',
|
url: '/callback',
|
||||||
|
|||||||
142
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
142
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
@@ -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<string, string | number>[] =
|
||||||
|
type === 'page'
|
||||||
|
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
|
||||||
|
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
|
||||||
|
|
||||||
|
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||||
|
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||||
|
|
||||||
|
const maxClicks = Math.max(
|
||||||
|
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<h3 className="font-medium text-sm">Top {breakdownLabel.toLowerCase()}s</h3>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
data={[1, 2, 3, 4, 5]}
|
||||||
|
keyExtractor={(i) => String(i)}
|
||||||
|
getColumnPercentage={() => 0}
|
||||||
|
columns={[
|
||||||
|
{ name: breakdownLabel, width: 'w-full', render: () => <Skeleton className="h-4 w-2/3" /> },
|
||||||
|
{ name: 'Clicks', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||||
|
{ name: 'Impr.', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||||
|
{ name: 'CTR', width: '60px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||||
|
{ name: 'Pos.', width: '55px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
data={breakdownRows}
|
||||||
|
keyExtractor={(item) => String(item[breakdownKey])}
|
||||||
|
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: breakdownLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<span className="block truncate font-mono text-xs">
|
||||||
|
{String(item[breakdownKey])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.clicks as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.clicks as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impr.',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.impressions as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.impressions as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CTR',
|
||||||
|
width: '60px',
|
||||||
|
getSortValue: (item) => item.ctr as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{((item.ctr as number) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
getSortValue: (item) => item.position as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.position as number).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
217
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
@@ -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<Set<string>>(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 (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||||
|
{items.length === 0
|
||||||
|
? '0 results'
|
||||||
|
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={page < pageCount - 1}
|
||||||
|
canPreviousPage={page > 0}
|
||||||
|
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
pageIndex={page}
|
||||||
|
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{query.isLoading &&
|
||||||
|
[1, 2, 3].map((i) => (
|
||||||
|
<div className="space-y-2 p-4" key={i}>
|
||||||
|
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{paginatedItems.map((item) => {
|
||||||
|
const isOpen = expanded.has(item.query);
|
||||||
|
const winner = item.pages[0];
|
||||||
|
const avgCtr =
|
||||||
|
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.query}>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => toggle(item.query)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'row shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs',
|
||||||
|
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircleIcon className="size-3" />
|
||||||
|
{item.pages.length} pages
|
||||||
|
</div>
|
||||||
|
<span className="truncate font-medium text-sm">
|
||||||
|
{item.query}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-4">
|
||||||
|
<span className="whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||||
|
{item.totalImpressions.toLocaleString()} impr ·{' '}
|
||||||
|
{(avgCtr * 100).toFixed(1)}% avg CTR
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDownIcon
|
||||||
|
className={cn(
|
||||||
|
'size-3.5 text-muted-foreground transition-transform',
|
||||||
|
isOpen && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t bg-muted/20 px-4 py-3">
|
||||||
|
<p className="mb-3 text-muted-foreground text-xs leading-normal">
|
||||||
|
These pages all rank for{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
"{item.query}"
|
||||||
|
</span>
|
||||||
|
. Consider consolidating weaker pages into the top-ranking
|
||||||
|
one to concentrate link equity and avoid splitting clicks.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{item.pages.map((page, idx) => {
|
||||||
|
// Strip hash fragments — GSC sometimes returns heading
|
||||||
|
// anchor URLs (e.g. /page#section) as separate entries
|
||||||
|
let cleanUrl = page.page;
|
||||||
|
let origin = '';
|
||||||
|
let path = page.page;
|
||||||
|
try {
|
||||||
|
const u = new URL(page.page);
|
||||||
|
u.hash = '';
|
||||||
|
cleanUrl = u.toString();
|
||||||
|
origin = u.origin;
|
||||||
|
path = u.pathname + u.search;
|
||||||
|
} catch {
|
||||||
|
cleanUrl = page.page.split('#')[0] ?? page.page;
|
||||||
|
}
|
||||||
|
const isWinner = idx === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
|
||||||
|
key={page.page}
|
||||||
|
onClick={() =>
|
||||||
|
pushModal('PageDetails', {
|
||||||
|
type: 'page',
|
||||||
|
projectId,
|
||||||
|
value: cleanUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="size-3.5 shrink-0 rounded-sm"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display =
|
||||||
|
'none';
|
||||||
|
}}
|
||||||
|
src={`${apiUrl}/misc/favicon?url=${origin}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{path || page.page}
|
||||||
|
</span>
|
||||||
|
{isWinner && (
|
||||||
|
<span className="shrink-0 rounded bg-emerald-100 px-1 py-0.5 font-medium text-emerald-700 text-xs dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||||
|
#1
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||||
|
pos {page.position.toFixed(1)} ·{' '}
|
||||||
|
{(page.ctr * 100).toFixed(1)}% CTR ·{' '}
|
||||||
|
{page.impressions.toLocaleString()} impr
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
197
apps/start/src/components/page/gsc-clicks-chart.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
date: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||||
|
ChartData,
|
||||||
|
{ formatDate: (date: Date | string) => string }
|
||||||
|
>(({ data, context }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{context.formatDate(item.date)}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Clicks</span>
|
||||||
|
<span>{item.clicks.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
<ChartTooltipItem color={getChartColor(1)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Impressions</span>
|
||||||
|
<span>{item.impressions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GscClicksChartProps {
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
type: 'page' | 'query';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GscClicksChart({
|
||||||
|
projectId,
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
}: GscClicksChartProps) {
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
const formatDateShort = useFormatDateInterval({ interval, short: true });
|
||||||
|
const formatDateLong = useFormatDateInterval({ interval, short: false });
|
||||||
|
|
||||||
|
const dateInput = {
|
||||||
|
range,
|
||||||
|
startDate: startDate ?? undefined,
|
||||||
|
endDate: endDate ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageQuery = useQuery(
|
||||||
|
trpc.gsc.getPageDetails.queryOptions(
|
||||||
|
{ projectId, page: value, ...dateInput },
|
||||||
|
{ enabled: type === 'page' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryQuery = useQuery(
|
||||||
|
trpc.gsc.getQueryDetails.queryOptions(
|
||||||
|
{ projectId, query: value, ...dateInput },
|
||||||
|
{ enabled: type === 'query' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||||
|
const timeseries =
|
||||||
|
(type === 'page'
|
||||||
|
? pageQuery.data?.timeseries
|
||||||
|
: queryQuery.data?.timeseries) ?? [];
|
||||||
|
|
||||||
|
const data: ChartData[] = timeseries.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
clicks: r.clicks,
|
||||||
|
impressions: r.impressions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-sm">Clicks & Impressions</h3>
|
||||||
|
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-0.5 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getChartColor(0) }}
|
||||||
|
/>
|
||||||
|
Clicks
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-0.5 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getChartColor(1) }}
|
||||||
|
/>
|
||||||
|
Impressions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<TooltipProvider formatDate={formatDateLong}>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-clicks-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => formatDateShort(v)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="clicks"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-clicks-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="impressions"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-clicks-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(1)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
228
apps/start/src/components/page/gsc-ctr-benchmark.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
// Industry average CTR by position (Google organic)
|
||||||
|
const BENCHMARK: Record<number, number> = {
|
||||||
|
1: 28.5,
|
||||||
|
2: 15.7,
|
||||||
|
3: 11.0,
|
||||||
|
4: 8.0,
|
||||||
|
5: 6.3,
|
||||||
|
6: 5.0,
|
||||||
|
7: 4.0,
|
||||||
|
8: 3.3,
|
||||||
|
9: 2.8,
|
||||||
|
10: 2.5,
|
||||||
|
11: 2.2,
|
||||||
|
12: 2.0,
|
||||||
|
13: 1.8,
|
||||||
|
14: 1.5,
|
||||||
|
15: 1.2,
|
||||||
|
16: 1.1,
|
||||||
|
17: 1.0,
|
||||||
|
18: 0.9,
|
||||||
|
19: 0.8,
|
||||||
|
20: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageEntry {
|
||||||
|
path: string;
|
||||||
|
ctr: number;
|
||||||
|
impressions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
position: number;
|
||||||
|
yourCtr: number | null;
|
||||||
|
benchmark: number;
|
||||||
|
pages: PageEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||||
|
ChartData,
|
||||||
|
Record<string, unknown>
|
||||||
|
>(({ data }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>Position #{item.position}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
{item.yourCtr != null && (
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Your avg CTR</span>
|
||||||
|
<span>{item.yourCtr.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
)}
|
||||||
|
<ChartTooltipItem color={getChartColor(3)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Benchmark</span>
|
||||||
|
<span>{item.benchmark.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
{item.pages.length > 0 && (
|
||||||
|
<div className="mt-1.5 border-t pt-1.5">
|
||||||
|
{item.pages.map((p) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-4 py-0.5"
|
||||||
|
key={p.path}
|
||||||
|
>
|
||||||
|
<span className="max-w-40 truncate font-mono text-muted-foreground text-xs">
|
||||||
|
{p.path}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs tabular-nums">
|
||||||
|
{(p.ctr * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GscCtrBenchmarkProps {
|
||||||
|
data: Array<{
|
||||||
|
page: string;
|
||||||
|
position: number;
|
||||||
|
ctr: number;
|
||||||
|
impressions: number;
|
||||||
|
}>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GscCtrBenchmark({ data, isLoading }: GscCtrBenchmarkProps) {
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
const grouped = new Map<number, { ctrSum: number; pages: PageEntry[] }>();
|
||||||
|
for (const d of data) {
|
||||||
|
const pos = Math.round(d.position);
|
||||||
|
if (pos < 1 || pos > 20 || d.impressions < 10) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = d.page;
|
||||||
|
try {
|
||||||
|
path = new URL(d.page).pathname;
|
||||||
|
} catch {
|
||||||
|
// keep as-is
|
||||||
|
}
|
||||||
|
const entry = grouped.get(pos) ?? { ctrSum: 0, pages: [] };
|
||||||
|
entry.ctrSum += d.ctr * 100;
|
||||||
|
entry.pages.push({ path, ctr: d.ctr, impressions: d.impressions });
|
||||||
|
grouped.set(pos, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartData[] = Array.from({ length: 20 }, (_, i) => {
|
||||||
|
const pos = i + 1;
|
||||||
|
const entry = grouped.get(pos);
|
||||||
|
const pages = entry
|
||||||
|
? [...entry.pages].sort((a, b) => b.ctr - a.ctr).slice(0, 5)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
position: pos,
|
||||||
|
yourCtr: entry ? entry.ctrSum / entry.pages.length : null,
|
||||||
|
benchmark: BENCHMARK[pos] ?? 0,
|
||||||
|
pages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAnyData = chartData.some((d) => d.yourCtr != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-sm">CTR vs Position</h3>
|
||||||
|
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||||
|
{hasAnyData && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-0.5 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getChartColor(0) }}
|
||||||
|
/>
|
||||||
|
Your CTR
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-0.5 w-3 rounded-full opacity-60"
|
||||||
|
style={{ backgroundColor: getChartColor(3) }}
|
||||||
|
/>
|
||||||
|
Benchmark
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<TooltipProvider>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={chartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="position"
|
||||||
|
domain={[1, 20]}
|
||||||
|
tickFormatter={(v: number) => `#${v}`}
|
||||||
|
ticks={[1, 5, 10, 15, 20]}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...yAxisProps}
|
||||||
|
domain={[0, 'auto']}
|
||||||
|
tickFormatter={(v: number) => `${v}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
dataKey="yourCtr"
|
||||||
|
dot={{ r: 3, fill: getChartColor(0) }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="benchmark"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(3)}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
strokeOpacity={0.6}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
129
apps/start/src/components/page/gsc-position-chart.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
date: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||||
|
ChartData,
|
||||||
|
Record<string, unknown>
|
||||||
|
>(({ data }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{item.date}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(2)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Avg Position</span>
|
||||||
|
<span>{item.position.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GscPositionChartProps {
|
||||||
|
data: Array<{ date: string; position: number }>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GscPositionChart({ data, isLoading }: GscPositionChartProps) {
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
const chartData: ChartData[] = data.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
position: r.position,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const positions = chartData.map((d) => d.position).filter((p) => p > 0);
|
||||||
|
const minPos = positions.length ? Math.max(1, Math.floor(Math.min(...positions)) - 2) : 1;
|
||||||
|
const maxPos = positions.length ? Math.ceil(Math.max(...positions)) + 2 : 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-sm">Avg Position</h3>
|
||||||
|
<span className="text-muted-foreground text-xs">Lower is better</span>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<TooltipProvider>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={chartData}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-pos-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
{...yAxisProps}
|
||||||
|
domain={[minPos, maxPos]}
|
||||||
|
reversed
|
||||||
|
tickFormatter={(v: number) => `#${v}`}
|
||||||
|
/>
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="position"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-pos-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(2)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
180
apps/start/src/components/page/page-views-chart.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
date: string;
|
||||||
|
pageviews: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip } = createChartTooltip<
|
||||||
|
ChartData,
|
||||||
|
{ formatDate: (date: Date | string) => string }
|
||||||
|
>(({ data, context }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{context.formatDate(item.date)}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Views</span>
|
||||||
|
<span>{item.pageviews.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
<ChartTooltipItem color={getChartColor(1)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Sessions</span>
|
||||||
|
<span>{item.sessions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PageViewsChartProps {
|
||||||
|
projectId: string;
|
||||||
|
origin: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageViewsChart({
|
||||||
|
projectId,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
}: PageViewsChartProps) {
|
||||||
|
const { range, interval } = useOverviewOptions();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
const formatDateShort = useFormatDateInterval({ interval, short: true });
|
||||||
|
const formatDateLong = useFormatDateInterval({ interval, short: false });
|
||||||
|
|
||||||
|
const query = useQuery(
|
||||||
|
trpc.event.pageTimeseries.queryOptions({
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const data: ChartData[] = (query.data ?? []).map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
pageviews: r.pageviews,
|
||||||
|
sessions: r.sessions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-sm">Views & Sessions</h3>
|
||||||
|
<div className="flex items-center gap-4 text-muted-foreground text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-0.5 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getChartColor(0) }}
|
||||||
|
/>
|
||||||
|
Views
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-0.5 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getChartColor(1) }}
|
||||||
|
/>
|
||||||
|
Sessions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<TooltipProvider formatDate={formatDateLong}>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="page-views-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => formatDateShort(v)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="pageviews"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#page-views-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="sessions"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#page-views-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(1)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
332
apps/start/src/components/page/pages-insights.tsx
Normal file
332
apps/start/src/components/page/pages-insights.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
AlertTriangleIcon,
|
||||||
|
EyeIcon,
|
||||||
|
MousePointerClickIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { Pagination } from '@/components/pagination';
|
||||||
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
type InsightType =
|
||||||
|
| 'low_ctr'
|
||||||
|
| 'near_page_one'
|
||||||
|
| 'invisible_clicks'
|
||||||
|
| 'high_bounce';
|
||||||
|
|
||||||
|
interface PageInsight {
|
||||||
|
page: string;
|
||||||
|
origin: string;
|
||||||
|
path: string;
|
||||||
|
type: InsightType;
|
||||||
|
impact: number;
|
||||||
|
headline: string;
|
||||||
|
suggestion: string;
|
||||||
|
metrics: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSIGHT_CONFIG: Record<
|
||||||
|
InsightType,
|
||||||
|
{ label: string; icon: React.ElementType; color: string; bg: string }
|
||||||
|
> = {
|
||||||
|
low_ctr: {
|
||||||
|
label: 'Low CTR',
|
||||||
|
icon: MousePointerClickIcon,
|
||||||
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
|
bg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||||
|
},
|
||||||
|
near_page_one: {
|
||||||
|
label: 'Near page 1',
|
||||||
|
icon: TrendingUpIcon,
|
||||||
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
|
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
|
},
|
||||||
|
invisible_clicks: {
|
||||||
|
label: 'Low visibility',
|
||||||
|
icon: EyeIcon,
|
||||||
|
color: 'text-violet-600 dark:text-violet-400',
|
||||||
|
bg: 'bg-violet-100 dark:bg-violet-900/30',
|
||||||
|
},
|
||||||
|
high_bounce: {
|
||||||
|
label: 'High bounce',
|
||||||
|
icon: AlertTriangleIcon,
|
||||||
|
color: 'text-red-600 dark:text-red-400',
|
||||||
|
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PagesInsightsProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PagesInsights({ projectId }: PagesInsightsProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||||
|
const { apiUrl } = useAppContext();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pageSize = 8;
|
||||||
|
|
||||||
|
const dateInput = {
|
||||||
|
range,
|
||||||
|
startDate: startDate ?? undefined,
|
||||||
|
endDate: endDate ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const gscPagesQuery = useQuery(
|
||||||
|
trpc.gsc.getPages.queryOptions(
|
||||||
|
{ projectId, ...dateInput, limit: 1000 },
|
||||||
|
{ placeholderData: keepPreviousData }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyticsQuery = useQuery(
|
||||||
|
trpc.event.pages.queryOptions(
|
||||||
|
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval },
|
||||||
|
{ placeholderData: keepPreviousData }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const insights = useMemo<PageInsight[]>(() => {
|
||||||
|
const gscPages = gscPagesQuery.data ?? [];
|
||||||
|
const analyticsPages = analyticsQuery.data ?? [];
|
||||||
|
|
||||||
|
const analyticsMap = new Map(
|
||||||
|
analyticsPages.map((p) => [p.origin + p.path, p])
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: PageInsight[] = [];
|
||||||
|
|
||||||
|
for (const gsc of gscPages) {
|
||||||
|
let origin = '';
|
||||||
|
let path = gsc.page;
|
||||||
|
try {
|
||||||
|
const url = new URL(gsc.page);
|
||||||
|
origin = url.origin;
|
||||||
|
path = url.pathname + url.search;
|
||||||
|
} catch {
|
||||||
|
// keep as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
const analytics = analyticsMap.get(gsc.page);
|
||||||
|
|
||||||
|
// 1. Low CTR: ranking on page 1 but click rate is poor
|
||||||
|
if (gsc.position <= 10 && gsc.ctr < 0.04 && gsc.impressions >= 100) {
|
||||||
|
results.push({
|
||||||
|
page: gsc.page,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
type: 'low_ctr',
|
||||||
|
impact: gsc.impressions * (0.04 - gsc.ctr),
|
||||||
|
headline: `Ranking #${Math.round(gsc.position)} but only ${(gsc.ctr * 100).toFixed(1)}% CTR`,
|
||||||
|
suggestion:
|
||||||
|
'You are on page 1 but people rarely click. Rewrite your title tag and meta description to be more compelling and match search intent.',
|
||||||
|
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${(gsc.ctr * 100).toFixed(1)}% CTR`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Near page 1: just off the first page with decent visibility
|
||||||
|
if (gsc.position > 10 && gsc.position <= 20 && gsc.impressions >= 100) {
|
||||||
|
results.push({
|
||||||
|
page: gsc.page,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
type: 'near_page_one',
|
||||||
|
impact: gsc.impressions / gsc.position,
|
||||||
|
headline: `Position ${Math.round(gsc.position)} — one push from page 1`,
|
||||||
|
suggestion:
|
||||||
|
'A content refresh, more internal links, or a few backlinks could move this into the top 10 and dramatically increase clicks.',
|
||||||
|
metrics: `Pos ${Math.round(gsc.position)} · ${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Invisible clicks: high impressions but barely any clicks
|
||||||
|
if (gsc.impressions >= 500 && gsc.ctr < 0.01 && gsc.position > 10) {
|
||||||
|
results.push({
|
||||||
|
page: gsc.page,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
type: 'invisible_clicks',
|
||||||
|
impact: gsc.impressions,
|
||||||
|
headline: `${gsc.impressions.toLocaleString()} impressions but only ${gsc.clicks} clicks`,
|
||||||
|
suggestion:
|
||||||
|
'Google shows this page a lot, but it almost never gets clicked. Consider whether the page targets the right queries or if a different format (e.g. listicle, how-to) would perform better.',
|
||||||
|
metrics: `${gsc.impressions.toLocaleString()} impr · ${gsc.clicks} clicks · Pos ${Math.round(gsc.position)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. High bounce: good traffic but poor engagement (requires analytics match)
|
||||||
|
if (
|
||||||
|
analytics &&
|
||||||
|
analytics.bounce_rate >= 70 &&
|
||||||
|
analytics.sessions >= 20
|
||||||
|
) {
|
||||||
|
results.push({
|
||||||
|
page: gsc.page,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
type: 'high_bounce',
|
||||||
|
impact: analytics.sessions * (analytics.bounce_rate / 100),
|
||||||
|
headline: `${Math.round(analytics.bounce_rate)}% bounce rate on a page with ${analytics.sessions} sessions`,
|
||||||
|
suggestion:
|
||||||
|
'Visitors are leaving without engaging. Check if the page delivers on its title/meta promise, improve page speed, and make sure key content is above the fold.',
|
||||||
|
metrics: `${Math.round(analytics.bounce_rate)}% bounce · ${analytics.sessions} sessions · ${gsc.impressions.toLocaleString()} impr`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check analytics pages without GSC match for high bounce
|
||||||
|
for (const p of analyticsPages) {
|
||||||
|
const fullUrl = p.origin + p.path;
|
||||||
|
if (
|
||||||
|
!gscPagesQuery.data?.some((g) => g.page === fullUrl) &&
|
||||||
|
p.bounce_rate >= 75 &&
|
||||||
|
p.sessions >= 30
|
||||||
|
) {
|
||||||
|
results.push({
|
||||||
|
page: fullUrl,
|
||||||
|
origin: p.origin,
|
||||||
|
path: p.path,
|
||||||
|
type: 'high_bounce',
|
||||||
|
impact: p.sessions * (p.bounce_rate / 100),
|
||||||
|
headline: `${Math.round(p.bounce_rate)}% bounce rate with ${p.sessions} sessions`,
|
||||||
|
suggestion:
|
||||||
|
'High bounce rate with no search visibility. Review content quality and check if the page is indexed and targeting the right keywords.',
|
||||||
|
metrics: `${Math.round(p.bounce_rate)}% bounce · ${p.sessions} sessions`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe by (page, type), keep highest impact
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = results.filter((r) => {
|
||||||
|
const key = `${r.page}::${r.type}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return deduped.sort((a, b) => b.impact - a.impact);
|
||||||
|
}, [gscPagesQuery.data, analyticsQuery.data]);
|
||||||
|
|
||||||
|
const isLoading = gscPagesQuery.isLoading || analyticsQuery.isLoading;
|
||||||
|
|
||||||
|
const pageCount = Math.ceil(insights.length / pageSize) || 1;
|
||||||
|
const paginatedInsights = useMemo(
|
||||||
|
() => insights.slice(page * pageSize, (page + 1) * pageSize),
|
||||||
|
[insights, page, pageSize]
|
||||||
|
);
|
||||||
|
const rangeStart = insights.length ? page * pageSize + 1 : 0;
|
||||||
|
const rangeEnd = Math.min((page + 1) * pageSize, insights.length);
|
||||||
|
|
||||||
|
if (!isLoading && !insights.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-sm">Opportunities</h3>
|
||||||
|
{insights.length > 0 && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||||
|
{insights.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{insights.length > 0 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||||
|
{insights.length === 0
|
||||||
|
? '0 results'
|
||||||
|
: `${rangeStart}-${rangeEnd} of ${insights.length}`}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={page < pageCount - 1}
|
||||||
|
canPreviousPage={page > 0}
|
||||||
|
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
pageIndex={page}
|
||||||
|
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{isLoading &&
|
||||||
|
[1, 2, 3, 4].map((i) => (
|
||||||
|
<div className="flex items-start gap-3 p-4" key={i}>
|
||||||
|
<div className="mt-0.5 h-7 w-20 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-3 w-full animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{paginatedInsights.map((insight, i) => {
|
||||||
|
const config = INSIGHT_CONFIG[insight.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-start gap-3 p-4 text-left transition-colors hover:bg-muted/40"
|
||||||
|
key={`${insight.page}-${insight.type}-${i}`}
|
||||||
|
onClick={() =>
|
||||||
|
pushModal('PageDetails', {
|
||||||
|
type: 'page',
|
||||||
|
projectId,
|
||||||
|
value: insight.page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="col min-w-0 flex-1 gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="size-3.5 shrink-0 rounded-sm"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
src={`${apiUrl}/misc/favicon?url=${insight.origin}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium font-mono text-xs">
|
||||||
|
{insight.path || insight.page}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'row shrink-0 items-center gap-1 rounded-md px-1 py-0.5 font-medium text-xs',
|
||||||
|
config.color,
|
||||||
|
config.bg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{insight.headline}.
|
||||||
|
</span>{' '}
|
||||||
|
{insight.suggestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||||
|
{insight.metrics}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/start/src/components/pages/page-sparkline.tsx
Normal file
113
apps/start/src/components/pages/page-sparkline.tsx
Normal file
@@ -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 <div style={{ height, width }} />;
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg className="shrink-0" height={height} width={width}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const barH = Math.max(
|
||||||
|
2,
|
||||||
|
Math.round((d.pageviews / max) * (height * 0.8))
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
className="fill-chart-0"
|
||||||
|
height={barH}
|
||||||
|
key={d.date}
|
||||||
|
rx="1"
|
||||||
|
width={barW}
|
||||||
|
x={i * (barW + gap)}
|
||||||
|
y={height - barH}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<Tooltiper
|
||||||
|
content={
|
||||||
|
trend === '↑'
|
||||||
|
? 'Upgoing trend'
|
||||||
|
: trend === '↓'
|
||||||
|
? 'Downgoing trend'
|
||||||
|
: 'Stable trend'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
|
||||||
|
{trend}
|
||||||
|
</span>
|
||||||
|
</Tooltiper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageSparklineProps {
|
||||||
|
projectId: string;
|
||||||
|
origin: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
|
||||||
|
const { range, interval } = useOverviewOptions();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const query = useQuery(
|
||||||
|
trpc.event.pageTimeseries.queryOptions({
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyComponent fallback={<div style={{ height, width }} />}>
|
||||||
|
<SparklineBars data={query.data ?? []} />
|
||||||
|
</LazyComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
apps/start/src/components/pages/table/columns.tsx
Normal file
199
apps/start/src/components/pages/table/columns.tsx
Normal file
@@ -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<string, number>;
|
||||||
|
}): ColumnDef<PageRow>[] {
|
||||||
|
const number = useNumber();
|
||||||
|
const { apiUrl } = useAppContext();
|
||||||
|
|
||||||
|
return useMemo<ColumnDef<PageRow>[]>(() => {
|
||||||
|
const cols: ColumnDef<PageRow>[] = [
|
||||||
|
{
|
||||||
|
id: 'page',
|
||||||
|
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
|
||||||
|
header: createHeaderColumn('Page'),
|
||||||
|
size: 400,
|
||||||
|
meta: { bold: true },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const page = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="size-4 shrink-0 rounded-sm"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{page.title && (
|
||||||
|
<div className="truncate font-medium text-sm leading-tight">
|
||||||
|
{page.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span className="truncate font-mono text-muted-foreground text-xs">
|
||||||
|
{page.path}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
|
||||||
|
href={page.origin + page.path}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trend',
|
||||||
|
header: 'Trend',
|
||||||
|
enableSorting: false,
|
||||||
|
size: 96,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PageSparkline
|
||||||
|
origin={row.original.origin}
|
||||||
|
path={row.original.path}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'pageviews',
|
||||||
|
header: createHeaderColumn('Views'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.pageviews)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'sessions',
|
||||||
|
header: createHeaderColumn('Sessions'),
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const prev = previousMap?.get(
|
||||||
|
row.original.origin + row.original.path
|
||||||
|
);
|
||||||
|
if (prev == null) {
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = ((row.original.sessions - prev) / prev) * 100;
|
||||||
|
const isPos = pct >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.sessions)}
|
||||||
|
</span>
|
||||||
|
{prev === 0 && <span className="text-muted-foreground">new</span>}
|
||||||
|
{prev > 0 && (
|
||||||
|
<span
|
||||||
|
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||||
|
>
|
||||||
|
{isPos ? '+' : ''}
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'bounce_rate',
|
||||||
|
header: createHeaderColumn('Bounce'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{row.original.bounce_rate.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'avg_duration',
|
||||||
|
header: createHeaderColumn('Duration'),
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
|
||||||
|
{fancyMinutes(row.original.avg_duration)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isGscConnected) {
|
||||||
|
cols.push(
|
||||||
|
{
|
||||||
|
id: 'gsc_impressions',
|
||||||
|
accessorFn: (row) => row.gsc?.impressions ?? 0,
|
||||||
|
header: createHeaderColumn('Impr.'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.gsc ? (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.gsc.impressions)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gsc_ctr',
|
||||||
|
accessorFn: (row) => row.gsc?.ctr ?? 0,
|
||||||
|
header: createHeaderColumn('CTR'),
|
||||||
|
size: 70,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.gsc ? (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{(row.original.gsc.ctr * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gsc_clicks',
|
||||||
|
accessorFn: (row) => row.gsc?.clicks ?? 0,
|
||||||
|
header: createHeaderColumn('Clicks'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.gsc ? (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.gsc.clicks)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
|
||||||
|
}
|
||||||
147
apps/start/src/components/pages/table/index.tsx
Normal file
147
apps/start/src/components/pages/table/index.tsx
Normal file
@@ -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<string, number>();
|
||||||
|
for (const p of previousPagesQuery.data ?? []) {
|
||||||
|
map.set(p.origin + p.path, p.sessions);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [previousPagesQuery.data]);
|
||||||
|
|
||||||
|
const gscMap = useMemo(() => {
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{ clicks: number; impressions: number; ctr: number; position: number }
|
||||||
|
>();
|
||||||
|
for (const row of gscPagesQuery.data ?? []) {
|
||||||
|
map.set(row.page, {
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [gscPagesQuery.data]);
|
||||||
|
|
||||||
|
const rawData: PageRow[] = useMemo(() => {
|
||||||
|
return (pagesQuery.data ?? []).map((p) => ({
|
||||||
|
...p,
|
||||||
|
gsc: gscMap.get(p.origin + p.path),
|
||||||
|
}));
|
||||||
|
}, [pagesQuery.data, gscMap]);
|
||||||
|
|
||||||
|
const 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 (
|
||||||
|
<>
|
||||||
|
<DataTableToolbarContainer>
|
||||||
|
<AnimatedSearchInput
|
||||||
|
placeholder="Search pages"
|
||||||
|
value={search ?? ''}
|
||||||
|
onChange={setSearch}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OverviewRange />
|
||||||
|
<OverviewInterval />
|
||||||
|
<DataTableViewOptions table={table} />
|
||||||
|
</div>
|
||||||
|
</DataTableToolbarContainer>
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
loading={pagesQuery.isLoading}
|
||||||
|
empty={{
|
||||||
|
title: 'No pages',
|
||||||
|
description: debouncedSearch
|
||||||
|
? `No pages found matching "${debouncedSearch}"`
|
||||||
|
: 'Integrate our web SDK to your site to get pages here.',
|
||||||
|
}}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (!isGscConnected) return;
|
||||||
|
const page = row.original;
|
||||||
|
pushModal('PageDetails', {
|
||||||
|
type: 'page',
|
||||||
|
projectId,
|
||||||
|
value: page.origin + page.path,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ const data = {
|
|||||||
"dropbox": "https://www.dropbox.com",
|
"dropbox": "https://www.dropbox.com",
|
||||||
"openai": "https://openai.com",
|
"openai": "https://openai.com",
|
||||||
"chatgpt.com": "https://chatgpt.com",
|
"chatgpt.com": "https://chatgpt.com",
|
||||||
|
"copilot.com": "https://www.copilot.com",
|
||||||
"mailchimp": "https://mailchimp.com",
|
"mailchimp": "https://mailchimp.com",
|
||||||
"activecampaign": "https://www.activecampaign.com",
|
"activecampaign": "https://www.activecampaign.com",
|
||||||
"customer.io": "https://customer.io",
|
"customer.io": "https://customer.io",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TrendingUpDownIcon,
|
TrendingUpDownIcon,
|
||||||
UndoDotIcon,
|
UndoDotIcon,
|
||||||
|
UserCircleIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
WallpaperIcon,
|
WallpaperIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -56,11 +57,11 @@ export default function SidebarProjectMenu({
|
|||||||
label="Insights"
|
label="Insights"
|
||||||
/>
|
/>
|
||||||
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
|
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
|
||||||
|
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
|
||||||
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
|
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
|
||||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||||
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
|
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
||||||
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
|
|
||||||
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||||
Manage
|
Manage
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface DataTableProps<TData> {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
onRowClick?: (row: import('@tanstack/react-table').Row<TData>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-table' {
|
declare module '@tanstack/react-table' {
|
||||||
@@ -35,6 +36,7 @@ export function DataTable<TData>({
|
|||||||
table,
|
table,
|
||||||
loading,
|
loading,
|
||||||
className,
|
className,
|
||||||
|
onRowClick,
|
||||||
empty = {
|
empty = {
|
||||||
title: 'No data',
|
title: 'No data',
|
||||||
description: 'We could not find any data here yet',
|
description: 'We could not find any data here yet',
|
||||||
@@ -78,6 +80,8 @@ export function DataTable<TData>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
className={onRowClick ? 'cursor-pointer' : undefined}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getISOWeek } from 'date-fns';
|
||||||
|
|
||||||
import type { IInterval } from '@openpanel/validation';
|
import type { IInterval } from '@openpanel/validation';
|
||||||
|
|
||||||
export function formatDateInterval(options: {
|
export function formatDateInterval(options: {
|
||||||
@@ -8,15 +10,19 @@ export function formatDateInterval(options: {
|
|||||||
const { interval, date, short } = options;
|
const { interval, date, short } = options;
|
||||||
try {
|
try {
|
||||||
if (interval === 'hour' || interval === 'minute') {
|
if (interval === 'hour' || interval === 'minute') {
|
||||||
|
if (short) {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
...(!short
|
month: '2-digit',
|
||||||
? {
|
day: '2-digit',
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +31,9 @@ export function formatDateInterval(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (interval === 'week') {
|
if (interval === 'week') {
|
||||||
|
if (short) {
|
||||||
|
return `W${getISOWeek(date)}`;
|
||||||
|
}
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -33,6 +42,12 @@ export function formatDateInterval(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (interval === 'day') {
|
if (interval === 'day') {
|
||||||
|
if (short) {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
return new Intl.DateTimeFormat('en-GB', {
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -41,7 +56,7 @@ export function formatDateInterval(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
} catch (e) {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
392
apps/start/src/modals/gsc-details.tsx
Normal file
392
apps/start/src/modals/gsc-details.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
type GscChartData = { date: string; clicks: number; impressions: number };
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||||
|
GscChartData,
|
||||||
|
Record<string, never>
|
||||||
|
>(({ data }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{item.date}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Clicks</span>
|
||||||
|
<span>{item.clicks.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
<ChartTooltipItem color={getChartColor(1)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Impressions</span>
|
||||||
|
<span>{item.impressions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
|
||||||
|
{value}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="col gap-6">
|
||||||
|
{type === 'page' && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<GscViewsChart
|
||||||
|
data={pagesTimeseries
|
||||||
|
.filter((r) => r.origin + r.path === value)
|
||||||
|
.map((r) => ({ date: r.date, views: r.pageviews }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<GscTimeseriesChart data={timeseries} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
Top {breakdownLabel.toLowerCase()}s
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: breakdownLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
render: () => <Skeleton className="h-4 w-10" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
render: () => <Skeleton className="h-4 w-8" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={[1, 2, 3, 4, 5]}
|
||||||
|
getColumnPercentage={() => 0}
|
||||||
|
keyExtractor={(i) => String(i)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: breakdownLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<span className="block truncate font-mono text-xs">
|
||||||
|
{String(item[breakdownKey])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.clicks as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.clicks as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impr.',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.impressions as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.impressions as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CTR',
|
||||||
|
width: '60px',
|
||||||
|
getSortValue: (item) => item.ctr as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{((item.ctr as number) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
getSortValue: (item) => item.position as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.position as number).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={breakdownRows as Record<string, string | number>[]}
|
||||||
|
getColumnPercentage={(item) =>
|
||||||
|
(item.clicks as number) / maxClicks
|
||||||
|
}
|
||||||
|
keyExtractor={(item) => String(item[breakdownKey])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GscViewsChart({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Array<{ date: string; views: number }>;
|
||||||
|
}) {
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider data={[]}>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-detail-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<GscTooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="views"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-detail-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GscTimeseriesChart({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Array<{ date: string; clicks: number; impressions: number }>;
|
||||||
|
}) {
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider data={data}>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-detail-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<GscTooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="clicks"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-detail-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="impressions"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-detail-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(1)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PageDetails from './page-details';
|
||||||
import { createPushModal } from 'pushmodal';
|
import { createPushModal } from 'pushmodal';
|
||||||
import AddClient from './add-client';
|
import AddClient from './add-client';
|
||||||
import AddDashboard from './add-dashboard';
|
import AddDashboard from './add-dashboard';
|
||||||
@@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
|
|||||||
import { op } from '@/utils/op';
|
import { op } from '@/utils/op';
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
|
PageDetails,
|
||||||
OverviewTopPagesModal,
|
OverviewTopPagesModal,
|
||||||
OverviewTopGenericModal,
|
OverviewTopGenericModal,
|
||||||
RequestPasswordReset,
|
RequestPasswordReset,
|
||||||
|
|||||||
46
apps/start/src/modals/page-details.tsx
Normal file
46
apps/start/src/modals/page-details.tsx
Normal file
@@ -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 (
|
||||||
|
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
|
||||||
|
{value}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="col gap-6">
|
||||||
|
{type === 'page' &&
|
||||||
|
(() => {
|
||||||
|
let origin = 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 (
|
||||||
|
<PageViewsChart
|
||||||
|
origin={origin}
|
||||||
|
path={path}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<GscClicksChart projectId={projectId} type={type} value={value} />
|
||||||
|
<GscBreakdownTable projectId={projectId} type={type} value={value} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,349 +1,22 @@
|
|||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { PagesTable } from '@/components/pages/table';
|
||||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
|
||||||
import { OverviewRange } from '@/components/overview/overview-range';
|
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
|
||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
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 { 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 { 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')({
|
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
|
||||||
component: Component,
|
component: Component,
|
||||||
head: () => {
|
head: () => ({
|
||||||
return {
|
meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }],
|
||||||
meta: [
|
}),
|
||||||
{
|
|
||||||
title: createProjectTitle(PAGE_TITLES.PAGES),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function Component() {
|
function Component() {
|
||||||
const { projectId } = Route.useParams();
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader title="Pages" description="Access all your pages here" className="mb-8" />
|
||||||
title="Pages"
|
<PagesTable projectId={projectId} />
|
||||||
description="Access all your pages here"
|
|
||||||
className="mb-8"
|
|
||||||
/>
|
|
||||||
<TableButtons>
|
|
||||||
<OverviewRange />
|
|
||||||
<OverviewInterval />
|
|
||||||
<Input
|
|
||||||
className="self-auto"
|
|
||||||
placeholder="Search path"
|
|
||||||
value={search ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setCursor(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableButtons>
|
|
||||||
{data.length === 0 && !isLoading && (
|
|
||||||
<FullPageEmptyState
|
|
||||||
title="No pages"
|
|
||||||
description={
|
|
||||||
debouncedSearch
|
|
||||||
? `No pages found matching "${debouncedSearch}"`
|
|
||||||
: 'Integrate our web sdk to your site to get pages here.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<PageCardSkeleton />
|
|
||||||
<PageCardSkeleton />
|
|
||||||
<PageCardSkeleton />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{data.map((page) => {
|
|
||||||
return (
|
|
||||||
<PageCard
|
|
||||||
key={page.origin + page.path}
|
|
||||||
page={page}
|
|
||||||
range={range}
|
|
||||||
interval={interval}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{allData.length !== 0 && (
|
|
||||||
<div className="p-4">
|
|
||||||
<FloatingPagination
|
|
||||||
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
|
|
||||||
canNextPage={cursor < totalPages}
|
|
||||||
canPreviousPage={cursor > 1}
|
|
||||||
pageIndex={cursor - 1}
|
|
||||||
nextPage={() => {
|
|
||||||
setCursor((p) => Math.min(p + 1, totalPages));
|
|
||||||
}}
|
|
||||||
previousPage={() => {
|
|
||||||
setCursor((p) => Math.max(p - 1, 1));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageCard = memo(
|
|
||||||
({
|
|
||||||
page,
|
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
page: RouterOutputs['event']['pages'][number];
|
|
||||||
range: IChartRange;
|
|
||||||
interval: IInterval;
|
|
||||||
projectId: string;
|
|
||||||
}) => {
|
|
||||||
const number = useNumber();
|
|
||||||
const { apiUrl } = useAppContext();
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
|
||||||
<div className="row gap-2 items-center h-16">
|
|
||||||
<img
|
|
||||||
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
|
|
||||||
alt={page.title}
|
|
||||||
className="size-10 rounded-sm object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
<div className="col min-w-0">
|
|
||||||
<div className="font-medium leading-[28px] truncate">
|
|
||||||
{page.title}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
href={`${page.origin}${page.path}`}
|
|
||||||
className="text-muted-foreground font-mono truncate hover:underline"
|
|
||||||
>
|
|
||||||
{page.path}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row border-y">
|
|
||||||
<div className="center-center col flex-1 p-4 py-2">
|
|
||||||
<div className="font-medium text-xl font-mono">
|
|
||||||
{number.formatWithUnit(page.avg_duration, 'min')}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
|
||||||
duration
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="center-center col flex-1 p-4 py-2">
|
|
||||||
<div className="font-medium text-xl font-mono">
|
|
||||||
{number.formatWithUnit(page.bounce_rate / 100, '%')}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
|
||||||
bounce rate
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="center-center col flex-1 p-4 py-2">
|
|
||||||
<div className="font-medium text-xl font-mono">
|
|
||||||
{number.format(page.sessions)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
|
||||||
sessions
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ReportChart
|
|
||||||
options={{
|
|
||||||
hideXAxis: true,
|
|
||||||
hideYAxis: true,
|
|
||||||
aspectRatio: 0.15,
|
|
||||||
}}
|
|
||||||
report={{
|
|
||||||
breakdowns: [],
|
|
||||||
metric: 'sum',
|
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
previous: true,
|
|
||||||
chartType: 'linear',
|
|
||||||
projectId,
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'event',
|
|
||||||
id: 'A',
|
|
||||||
name: 'screen_view',
|
|
||||||
segment: 'event',
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
id: 'path',
|
|
||||||
name: 'path',
|
|
||||||
value: [page.path],
|
|
||||||
operator: 'is',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'origin',
|
|
||||||
name: 'origin',
|
|
||||||
value: [page.origin],
|
|
||||||
operator: 'is',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const PageCardSkeleton = memo(() => {
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
|
||||||
<div className="row gap-2 items-center h-16">
|
|
||||||
<Skeleton className="size-10 rounded-sm" />
|
|
||||||
<div className="col min-w-0">
|
|
||||||
<Skeleton className="h-3 w-32 mb-2" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row border-y">
|
|
||||||
<div className="center-center col flex-1 p-4 py-2">
|
|
||||||
<Skeleton className="h-6 w-16 mb-1" />
|
|
||||||
<Skeleton className="h-4 w-12" />
|
|
||||||
</div>
|
|
||||||
<div className="center-center col flex-1 p-4 py-2">
|
|
||||||
<Skeleton className="h-6 w-12 mb-1" />
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
</div>
|
|
||||||
<div className="center-center col flex-1 p-4 py-2">
|
|
||||||
<Skeleton className="h-6 w-14 mb-1" />
|
|
||||||
<Skeleton className="h-4 w-14" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<Skeleton className="h-16 w-full rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 { Skeleton } from '@/components/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -10,12 +16,6 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
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(
|
export const Route = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||||
@@ -46,14 +46,7 @@ function GscSettings() {
|
|||||||
const initiateOAuth = useMutation(
|
const initiateOAuth = useMutation(
|
||||||
trpc.gsc.initiateOAuth.mutationOptions({
|
trpc.gsc.initiateOAuth.mutationOptions({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
// Route through the API /gsc/initiate endpoint which sets cookies then redirects to Google
|
window.location.href = data.url;
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Failed to initiate Google Search Console connection');
|
toast.error('Failed to initiate Google Search Console connection');
|
||||||
@@ -102,19 +95,21 @@ function GscSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Google Search Console</h3>
|
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
Connect your Google Search Console property to import search performance data.
|
Connect your Google Search Console property to import search
|
||||||
|
performance data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-6 flex flex-col gap-4">
|
<div className="flex flex-col gap-4 rounded-lg border p-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
You will be redirected to Google to authorize access. Only read-only access to Search Console data is requested.
|
You will be redirected to Google to authorize access. Only read-only
|
||||||
|
access to Search Console data is requested.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
onClick={() => initiateOAuth.mutate({ projectId })}
|
|
||||||
disabled={initiateOAuth.isPending}
|
disabled={initiateOAuth.isPending}
|
||||||
|
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||||
>
|
>
|
||||||
{initiateOAuth.isPending && (
|
{initiateOAuth.isPending && (
|
||||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -132,21 +127,22 @@ function GscSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Select a property</h3>
|
<h3 className="font-medium text-lg">Select a property</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
Choose which Google Search Console property to connect to this project.
|
Choose which Google Search Console property to connect to this
|
||||||
|
project.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-6 space-y-4">
|
<div className="space-y-4 rounded-lg border p-6">
|
||||||
{sitesQuery.isLoading ? (
|
{sitesQuery.isLoading ? (
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
) : sites.length === 0 ? (
|
) : sites.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
No Search Console properties found for this Google account.
|
No Search Console properties found for this Google account.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select value={selectedSite} onValueChange={setSelectedSite}>
|
<Select onValueChange={setSelectedSite} value={selectedSite}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a property..." />
|
<SelectValue placeholder="Select a property..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -160,7 +156,9 @@ function GscSettings() {
|
|||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
disabled={!selectedSite || selectSite.isPending}
|
disabled={!selectedSite || selectSite.isPending}
|
||||||
onClick={() => selectSite.mutate({ projectId, siteUrl: selectedSite })}
|
onClick={() =>
|
||||||
|
selectSite.mutate({ projectId, siteUrl: selectedSite })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{selectSite.isPending && (
|
{selectSite.isPending && (
|
||||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -171,9 +169,9 @@ function GscSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => disconnect.mutate({ projectId })}
|
onClick={() => disconnect.mutate({ projectId })}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -181,6 +179,56 @@ function GscSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token expired — show reconnect prompt
|
||||||
|
if (connection.lastSyncStatus === 'token_expired') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
Connected to Google Search Console.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span className="font-medium text-sm">Authorization expired</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your Google Search Console authorization has expired or been
|
||||||
|
revoked. Please reconnect to continue syncing data.
|
||||||
|
</p>
|
||||||
|
{connection.lastSyncError && (
|
||||||
|
<p className="break-words font-mono text-muted-foreground text-xs">
|
||||||
|
{connection.lastSyncError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
disabled={initiateOAuth.isPending}
|
||||||
|
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||||
|
>
|
||||||
|
{initiateOAuth.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Reconnect Google Search Console
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={disconnect.isPending}
|
||||||
|
onClick={() => disconnect.mutate({ projectId })}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{disconnect.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Fully connected
|
// Fully connected
|
||||||
const syncStatusIcon =
|
const syncStatusIcon =
|
||||||
connection.lastSyncStatus === 'success' ? (
|
connection.lastSyncStatus === 'success' ? (
|
||||||
@@ -199,28 +247,35 @@ function GscSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Google Search Console</h3>
|
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
Connected to Google Search Console.
|
Connected to Google Search Console.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border divide-y">
|
<div className="divide-y rounded-lg border">
|
||||||
<div className="p-4 flex items-center justify-between">
|
<div className="flex items-center justify-between p-4">
|
||||||
<div className="text-sm font-medium">Property</div>
|
<div className="font-medium text-sm">Property</div>
|
||||||
<div className="font-mono text-sm text-muted-foreground">
|
<div className="font-mono text-muted-foreground text-sm">
|
||||||
{connection.siteUrl}
|
{connection.siteUrl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{connection.backfillStatus && (
|
{connection.backfillStatus && (
|
||||||
<div className="p-4 flex items-center justify-between">
|
<div className="flex items-center justify-between p-4">
|
||||||
<div className="text-sm font-medium">Backfill</div>
|
<div className="font-medium text-sm">Backfill</div>
|
||||||
<Badge className="capitalize" variant={
|
<Badge
|
||||||
connection.backfillStatus === 'completed' ? 'success' :
|
className="capitalize"
|
||||||
connection.backfillStatus === 'failed' ? 'destructive' :
|
variant={
|
||||||
connection.backfillStatus === 'running' ? 'default' : 'secondary'
|
connection.backfillStatus === 'completed'
|
||||||
}>
|
? 'success'
|
||||||
|
: connection.backfillStatus === 'failed'
|
||||||
|
? 'destructive'
|
||||||
|
: connection.backfillStatus === 'running'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
{connection.backfillStatus === 'running' && (
|
{connection.backfillStatus === 'running' && (
|
||||||
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
|
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
|
||||||
)}
|
)}
|
||||||
@@ -230,16 +285,19 @@ function GscSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{connection.lastSyncedAt && (
|
{connection.lastSyncedAt && (
|
||||||
<div className="p-4 flex items-center justify-between">
|
<div className="flex items-center justify-between p-4">
|
||||||
<div className="text-sm font-medium">Last synced</div>
|
<div className="font-medium text-sm">Last synced</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{connection.lastSyncStatus && (
|
{connection.lastSyncStatus && (
|
||||||
<Badge className="capitalize" variant={syncStatusVariant as any}>
|
<Badge
|
||||||
|
className="capitalize"
|
||||||
|
variant={syncStatusVariant as any}
|
||||||
|
>
|
||||||
{syncStatusIcon}
|
{syncStatusIcon}
|
||||||
{connection.lastSyncStatus}
|
{connection.lastSyncStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
|
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
})}
|
})}
|
||||||
@@ -250,8 +308,10 @@ function GscSettings() {
|
|||||||
|
|
||||||
{connection.lastSyncError && (
|
{connection.lastSyncError && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="text-sm font-medium text-destructive">Last error</div>
|
<div className="font-medium text-destructive text-sm">
|
||||||
<div className="mt-1 text-sm text-muted-foreground font-mono break-words">
|
Last error
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
|
||||||
{connection.lastSyncError}
|
{connection.lastSyncError}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,10 +319,10 @@ function GscSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => disconnect.mutate({ projectId })}
|
|
||||||
disabled={disconnect.isPending}
|
disabled={disconnect.isPending}
|
||||||
|
onClick={() => disconnect.mutate({ projectId })}
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
{disconnect.isPending && (
|
{disconnect.isPending && (
|
||||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -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 ?? '',
|
|
||||||
);
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { GitHub } from 'arctic';
|
import { GitHub } from 'arctic';
|
||||||
|
|
||||||
export type { OAuth2Tokens } from 'arctic';
|
export type { OAuth2Tokens } from 'arctic';
|
||||||
|
|
||||||
import * as Arctic from 'arctic';
|
import * as Arctic from 'arctic';
|
||||||
|
|
||||||
export { Arctic };
|
export { Arctic };
|
||||||
@@ -8,17 +9,17 @@ export { Arctic };
|
|||||||
export const github = new GitHub(
|
export const github = new GitHub(
|
||||||
process.env.GITHUB_CLIENT_ID ?? '',
|
process.env.GITHUB_CLIENT_ID ?? '',
|
||||||
process.env.GITHUB_CLIENT_SECRET ?? '',
|
process.env.GITHUB_CLIENT_SECRET ?? '',
|
||||||
process.env.GITHUB_REDIRECT_URI ?? '',
|
process.env.GITHUB_REDIRECT_URI ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
export const google = new Arctic.Google(
|
export const google = new Arctic.Google(
|
||||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
process.env.GOOGLE_REDIRECT_URI ?? '',
|
process.env.GOOGLE_REDIRECT_URI ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
export const googleGsc = new Arctic.Google(
|
export const googleGsc = new Arctic.Google(
|
||||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
process.env.GSC_GOOGLE_REDIRECT_URI ?? '',
|
process.env.GSC_GOOGLE_REDIRECT_URI ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,3 +32,4 @@ export * from './src/services/pages.service';
|
|||||||
export * from './src/services/insights';
|
export * from './src/services/insights';
|
||||||
export * from './src/session-context';
|
export * from './src/session-context';
|
||||||
export * from './src/gsc';
|
export * from './src/gsc';
|
||||||
|
export * from './src/encryption';
|
||||||
|
|||||||
@@ -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;
|
||||||
44
packages/db/src/encryption.ts
Normal file
44
packages/db/src/encryption.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const TAG_LENGTH = 16;
|
||||||
|
const ENCODING = 'base64';
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const raw = process.env.ENCRYPTION_KEY;
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('ENCRYPTION_KEY environment variable is not set');
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(raw, 'hex');
|
||||||
|
if (buf.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encrypt(plaintext: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
// Format: base64(iv + tag + ciphertext)
|
||||||
|
return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(ciphertext: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
const buf = Buffer.from(ciphertext, ENCODING);
|
||||||
|
const iv = buf.subarray(0, IV_LENGTH);
|
||||||
|
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||||
|
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return decipher.update(encrypted) + decipher.final('utf8');
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { cacheable } from '@openpanel/redis';
|
||||||
import { originalCh } from './clickhouse/client';
|
import { originalCh } from './clickhouse/client';
|
||||||
|
import { decrypt, encrypt } from './encryption';
|
||||||
import { db } from './prisma-client';
|
import { db } from './prisma-client';
|
||||||
|
|
||||||
export interface GscSite {
|
export interface GscSite {
|
||||||
@@ -44,20 +46,36 @@ export async function getGscAccessToken(projectId: string): Promise<string> {
|
|||||||
conn.accessTokenExpiresAt &&
|
conn.accessTokenExpiresAt &&
|
||||||
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
|
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
|
||||||
) {
|
) {
|
||||||
return conn.accessToken;
|
return decrypt(conn.accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, expiresAt } = await refreshGscToken(conn.refreshToken);
|
try {
|
||||||
await db.gscConnection.update({
|
const { accessToken, expiresAt } = await refreshGscToken(
|
||||||
where: { projectId },
|
decrypt(conn.refreshToken)
|
||||||
data: { accessToken, accessTokenExpiresAt: expiresAt },
|
);
|
||||||
});
|
await db.gscConnection.update({
|
||||||
return accessToken;
|
where: { projectId },
|
||||||
|
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
|
||||||
|
});
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: {
|
||||||
|
lastSyncStatus: 'token_expired',
|
||||||
|
lastSyncError:
|
||||||
|
error instanceof Error ? error.message : 'Failed to refresh token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
'GSC token has expired or been revoked. Please reconnect Google Search Console.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGscSites(projectId: string): Promise<GscSite[]> {
|
export async function listGscSites(projectId: string): Promise<GscSite[]> {
|
||||||
const accessToken = await getGscAccessToken(projectId);
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
const res = await fetch('https://www.googleapis.com/webmaster/v3/sites', {
|
const res = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,15 +98,26 @@ interface GscApiRow {
|
|||||||
position: number;
|
position: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GscDimensionFilter {
|
||||||
|
dimension: string;
|
||||||
|
operator: string;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GscFilterGroup {
|
||||||
|
filters: GscDimensionFilter[];
|
||||||
|
}
|
||||||
|
|
||||||
async function queryGscSearchAnalytics(
|
async function queryGscSearchAnalytics(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
siteUrl: string,
|
siteUrl: string,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
dimensions: string[]
|
dimensions: string[],
|
||||||
|
dimensionFilterGroups?: GscFilterGroup[]
|
||||||
): Promise<GscApiRow[]> {
|
): Promise<GscApiRow[]> {
|
||||||
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
||||||
const url = `https://www.googleapis.com/webmaster/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
|
const url = `https://www.googleapis.com/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
|
||||||
|
|
||||||
const allRows: GscApiRow[] = [];
|
const allRows: GscApiRow[] = [];
|
||||||
let startRow = 0;
|
let startRow = 0;
|
||||||
@@ -108,6 +137,7 @@ async function queryGscSearchAnalytics(
|
|||||||
rowLimit,
|
rowLimit,
|
||||||
startRow,
|
startRow,
|
||||||
dataState: 'all',
|
dataState: 'all',
|
||||||
|
...(dimensionFilterGroups && { dimensionFilterGroups }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,7 +264,8 @@ export async function syncGscData(
|
|||||||
export async function getGscOverview(
|
export async function getGscOverview(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string
|
endDate: string,
|
||||||
|
interval: 'day' | 'week' | 'month' = 'day'
|
||||||
): Promise<
|
): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
date: string;
|
date: string;
|
||||||
@@ -244,10 +275,17 @@ export async function getGscOverview(
|
|||||||
position: number;
|
position: number;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
const dateExpr =
|
||||||
|
interval === 'month'
|
||||||
|
? 'toStartOfMonth(date)'
|
||||||
|
: interval === 'week'
|
||||||
|
? 'toStartOfWeek(date)'
|
||||||
|
: 'date';
|
||||||
|
|
||||||
const result = await originalCh.query({
|
const result = await originalCh.query({
|
||||||
query: `
|
query: `
|
||||||
SELECT
|
SELECT
|
||||||
date,
|
${dateExpr} as date,
|
||||||
sum(clicks) as clicks,
|
sum(clicks) as clicks,
|
||||||
sum(impressions) as impressions,
|
sum(impressions) as impressions,
|
||||||
avg(ctr) as ctr,
|
avg(ctr) as ctr,
|
||||||
@@ -303,6 +341,176 @@ export async function getGscPages(
|
|||||||
return result.json();
|
return result.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GscCannibalizedQuery {
|
||||||
|
query: string;
|
||||||
|
totalImpressions: number;
|
||||||
|
totalClicks: number;
|
||||||
|
pages: Array<{
|
||||||
|
page: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGscCannibalization = cacheable(
|
||||||
|
async (
|
||||||
|
projectId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
): Promise<GscCannibalizedQuery[]> => {
|
||||||
|
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||||
|
where: { projectId },
|
||||||
|
});
|
||||||
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
|
|
||||||
|
const rows = await queryGscSearchAnalytics(
|
||||||
|
accessToken,
|
||||||
|
conn.siteUrl,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
['query', 'page']
|
||||||
|
);
|
||||||
|
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
totalImpressions: number;
|
||||||
|
totalClicks: number;
|
||||||
|
pages: GscCannibalizedQuery['pages'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const query = row.keys[0] ?? '';
|
||||||
|
// Strip hash fragments — GSC records heading anchors (e.g. /page#section)
|
||||||
|
// as separate URLs but Google treats them as the same page
|
||||||
|
let page = row.keys[1] ?? '';
|
||||||
|
try {
|
||||||
|
const u = new URL(page);
|
||||||
|
u.hash = '';
|
||||||
|
page = u.toString();
|
||||||
|
} catch {
|
||||||
|
page = page.split('#')[0] ?? page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = map.get(query) ?? {
|
||||||
|
totalImpressions: 0,
|
||||||
|
totalClicks: 0,
|
||||||
|
pages: [],
|
||||||
|
};
|
||||||
|
entry.totalImpressions += row.impressions;
|
||||||
|
entry.totalClicks += row.clicks;
|
||||||
|
// Merge into existing page entry if already seen (from a different hash variant)
|
||||||
|
const existing = entry.pages.find((p) => p.page === page);
|
||||||
|
if (existing) {
|
||||||
|
existing.clicks += row.clicks;
|
||||||
|
existing.impressions += row.impressions;
|
||||||
|
existing.ctr = (existing.ctr + row.ctr) / 2;
|
||||||
|
existing.position = Math.min(existing.position, row.position);
|
||||||
|
} 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(
|
export async function getGscQueries(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
|
|||||||
@@ -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';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
|
|
||||||
export interface IGetPagesInput {
|
export interface IGetPagesInput {
|
||||||
@@ -9,6 +10,14 @@ export interface IGetPagesInput {
|
|||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPageTimeseriesRow {
|
||||||
|
origin: string;
|
||||||
|
path: string;
|
||||||
|
date: string;
|
||||||
|
pageviews: number;
|
||||||
|
sessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ITopPage {
|
export interface ITopPage {
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -72,7 +81,7 @@ export class PagesService {
|
|||||||
.leftJoin(
|
.leftJoin(
|
||||||
sessionsSubquery,
|
sessionsSubquery,
|
||||||
'e.session_id = s.id AND e.project_id = s.project_id',
|
'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')
|
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
|
||||||
.where('e.project_id', '=', projectId)
|
.where('e.project_id', '=', projectId)
|
||||||
@@ -91,6 +100,55 @@ export class PagesService {
|
|||||||
|
|
||||||
return query.execute();
|
return query.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPageTimeseries({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
interval,
|
||||||
|
filterOrigin,
|
||||||
|
filterPath,
|
||||||
|
}: IGetPagesInput & {
|
||||||
|
interval: IInterval;
|
||||||
|
filterOrigin?: string;
|
||||||
|
filterPath?: string;
|
||||||
|
}): Promise<IPageTimeseriesRow[]> {
|
||||||
|
const dateExpr = clix.toStartOf('e.created_at', interval, timezone);
|
||||||
|
const useDateOnly = interval === 'month' || interval === 'week';
|
||||||
|
const fillFrom = clix.toStartOf(
|
||||||
|
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
const fillTo = clix.datetime(
|
||||||
|
endDate,
|
||||||
|
useDateOnly ? 'toDate' : 'toDateTime'
|
||||||
|
);
|
||||||
|
const fillStep = clix.toInterval('1', interval);
|
||||||
|
|
||||||
|
return clix(this.client, timezone)
|
||||||
|
.select<IPageTimeseriesRow>([
|
||||||
|
'e.origin as origin',
|
||||||
|
'e.path as path',
|
||||||
|
`${dateExpr} AS date`,
|
||||||
|
'count() as pageviews',
|
||||||
|
'uniq(e.session_id) as sessions',
|
||||||
|
])
|
||||||
|
.from(`${TABLE_NAMES.events} e`, false)
|
||||||
|
.where('e.project_id', '=', projectId)
|
||||||
|
.where('e.name', '=', 'screen_view')
|
||||||
|
.where('e.path', '!=', '')
|
||||||
|
.where('e.created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
|
])
|
||||||
|
.when(!!filterOrigin, (q) => q.where('e.origin', '=', filterOrigin!))
|
||||||
|
.when(!!filterPath, (q) => q.where('e.path', '=', filterPath!))
|
||||||
|
.groupBy(['e.origin', 'e.path', 'date'])
|
||||||
|
.orderBy('date', 'ASC')
|
||||||
|
.fill(fillFrom, fillTo, fillStep)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pagesService = new PagesService(ch);
|
export const pagesService = new PagesService(ch);
|
||||||
|
|||||||
@@ -338,6 +338,79 @@ export const eventRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
origin: protectedProcedure
|
origin: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
import { Arctic, googleGsc } from '@openpanel/auth';
|
import { Arctic, googleGsc } from '@openpanel/auth';
|
||||||
import {
|
import {
|
||||||
|
chQuery,
|
||||||
db,
|
db,
|
||||||
|
getChartStartEndDate,
|
||||||
|
getGscCannibalization,
|
||||||
getGscOverview,
|
getGscOverview,
|
||||||
|
getGscPageDetails,
|
||||||
getGscPages,
|
getGscPages,
|
||||||
getGscQueries,
|
getGscQueries,
|
||||||
|
getGscQueryDetails,
|
||||||
|
getSettingsForProject,
|
||||||
listGscSites,
|
listGscSites,
|
||||||
|
TABLE_NAMES,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { gscQueue } from '@openpanel/queue';
|
import { gscQueue } from '@openpanel/queue';
|
||||||
|
import { zRange, zTimeInterval } from '@openpanel/validation';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getProjectAccess } from '../access';
|
import { getProjectAccess } from '../access';
|
||||||
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
|
const zGscDateInput = z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
range: zRange,
|
||||||
|
interval: zTimeInterval.optional().default('day'),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resolveDates(
|
||||||
|
projectId: string,
|
||||||
|
input: { range: string; startDate?: string; endDate?: string }
|
||||||
|
) {
|
||||||
|
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({
|
export const gscRouter = createTRPCRouter({
|
||||||
getConnection: protectedProcedure
|
getConnection: protectedProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
@@ -52,17 +85,17 @@ export const gscRouter = createTRPCRouter({
|
|||||||
const state = Arctic.generateState();
|
const state = Arctic.generateState();
|
||||||
const codeVerifier = Arctic.generateCodeVerifier();
|
const codeVerifier = Arctic.generateCodeVerifier();
|
||||||
const url = googleGsc.createAuthorizationURL(state, codeVerifier, [
|
const url = googleGsc.createAuthorizationURL(state, codeVerifier, [
|
||||||
'https://www.googleapis.com/auth/webmaster.readonly',
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||||
]);
|
]);
|
||||||
url.searchParams.set('access_type', 'offline');
|
url.searchParams.set('access_type', 'offline');
|
||||||
url.searchParams.set('prompt', 'consent');
|
url.searchParams.set('prompt', 'consent');
|
||||||
|
|
||||||
return {
|
const cookieOpts = { maxAge: 60 * 10 };
|
||||||
url: url.toString(),
|
ctx.setCookie('gsc_oauth_state', state, cookieOpts);
|
||||||
state,
|
ctx.setCookie('gsc_code_verifier', codeVerifier, cookieOpts);
|
||||||
codeVerifier,
|
ctx.setCookie('gsc_project_id', input.projectId, cookieOpts);
|
||||||
projectId: input.projectId,
|
|
||||||
};
|
return { url: url.toString() };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getSites: protectedProcedure
|
getSites: protectedProcedure
|
||||||
@@ -131,13 +164,7 @@ export const gscRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getOverview: protectedProcedure
|
getOverview: protectedProcedure
|
||||||
.input(
|
.input(zGscDateInput)
|
||||||
z.object({
|
|
||||||
projectId: z.string(),
|
|
||||||
startDate: z.string(),
|
|
||||||
endDate: z.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const access = await getProjectAccess({
|
const access = await getProjectAccess({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
@@ -146,15 +173,16 @@ export const gscRouter = createTRPCRouter({
|
|||||||
if (!access) {
|
if (!access) {
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
}
|
}
|
||||||
return getGscOverview(input.projectId, input.startDate, input.endDate);
|
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
|
getPages: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
zGscDateInput.extend({
|
||||||
projectId: z.string(),
|
|
||||||
startDate: z.string(),
|
|
||||||
endDate: z.string(),
|
|
||||||
limit: z.number().min(1).max(1000).optional().default(100),
|
limit: z.number().min(1).max(1000).optional().default(100),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -166,20 +194,46 @@ export const gscRouter = createTRPCRouter({
|
|||||||
if (!access) {
|
if (!access) {
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
}
|
}
|
||||||
return getGscPages(
|
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.projectId,
|
||||||
input.startDate,
|
input.query,
|
||||||
input.endDate,
|
startDate,
|
||||||
input.limit
|
endDate
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getQueries: protectedProcedure
|
getQueries: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
zGscDateInput.extend({
|
||||||
projectId: z.string(),
|
|
||||||
startDate: z.string(),
|
|
||||||
endDate: z.string(),
|
|
||||||
limit: z.number().min(1).max(1000).optional().default(100),
|
limit: z.number().min(1).max(1000).optional().default(100),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -191,11 +245,172 @@ export const gscRouter = createTRPCRouter({
|
|||||||
if (!access) {
|
if (!access) {
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
}
|
}
|
||||||
return getGscQueries(
|
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,
|
input.projectId,
|
||||||
input.startDate,
|
fmt(prevStart),
|
||||||
input.endDate,
|
fmt(prevEnd),
|
||||||
input.limit
|
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);
|
||||||
|
// Clear stale cache so hash-stripping fix applies immediately
|
||||||
|
await getGscCannibalization.clear(input.projectId, startDate, endDate);
|
||||||
|
return getGscCannibalization(input.projectId, startDate, endDate);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user