feat: added google search console
This commit is contained in:
committed by
GitHub
parent
70ca44f039
commit
271d189ed0
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { googleGsc } from '@openpanel/auth';
|
||||||
|
import { db, encrypt } from '@openpanel/db';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { LogError } from '@/utils/errors';
|
||||||
|
|
||||||
|
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
|
||||||
|
|
||||||
|
function sanitizeOAuthQuery(
|
||||||
|
query: Record<string, unknown> | null | undefined
|
||||||
|
): Record<string, string> {
|
||||||
|
if (!query || typeof query !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(query).map(([k, v]) => [
|
||||||
|
k,
|
||||||
|
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gscGoogleCallback(
|
||||||
|
req: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const schema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
state: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = schema.safeParse(req.query);
|
||||||
|
if (!query.success) {
|
||||||
|
throw new LogError(
|
||||||
|
'Invalid GSC callback query params',
|
||||||
|
sanitizeOAuthQuery(req.query as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, state } = query.data;
|
||||||
|
|
||||||
|
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
|
||||||
|
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
|
||||||
|
const rawProjectId = req.cookies.gsc_project_id ?? null;
|
||||||
|
|
||||||
|
const storedStateResult =
|
||||||
|
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
|
||||||
|
const codeVerifierResult =
|
||||||
|
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
|
||||||
|
const projectIdResult =
|
||||||
|
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
storedStateResult?.value &&
|
||||||
|
codeVerifierResult?.value &&
|
||||||
|
projectIdResult?.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new LogError('Missing GSC OAuth cookies', {
|
||||||
|
storedState: !storedStateResult?.value,
|
||||||
|
codeVerifier: !codeVerifierResult?.value,
|
||||||
|
projectId: !projectIdResult?.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
storedStateResult?.valid &&
|
||||||
|
codeVerifierResult?.valid &&
|
||||||
|
projectIdResult?.valid
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new LogError('Invalid GSC OAuth cookies', {
|
||||||
|
storedState: !storedStateResult?.value,
|
||||||
|
codeVerifier: !codeVerifierResult?.value,
|
||||||
|
projectId: !projectIdResult?.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateStr = storedStateResult?.value;
|
||||||
|
const codeVerifierStr = codeVerifierResult?.value;
|
||||||
|
const projectIdStr = projectIdResult?.value;
|
||||||
|
|
||||||
|
if (state !== stateStr) {
|
||||||
|
throw new LogError('GSC OAuth state mismatch', {
|
||||||
|
hasState: true,
|
||||||
|
hasStoredState: true,
|
||||||
|
stateMismatch: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await googleGsc.validateAuthorizationCode(
|
||||||
|
code,
|
||||||
|
codeVerifierStr
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = tokens.accessToken();
|
||||||
|
const refreshToken = tokens.hasRefreshToken()
|
||||||
|
? tokens.refreshToken()
|
||||||
|
: null;
|
||||||
|
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new LogError('No refresh token returned from Google GSC OAuth');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectIdStr },
|
||||||
|
select: { id: true, organizationId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new LogError('Project not found for GSC connection', {
|
||||||
|
projectId: projectIdStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.gscConnection.upsert({
|
||||||
|
where: { projectId: projectIdStr },
|
||||||
|
create: {
|
||||||
|
projectId: projectIdStr,
|
||||||
|
accessToken: encrypt(accessToken),
|
||||||
|
refreshToken: encrypt(refreshToken),
|
||||||
|
accessTokenExpiresAt,
|
||||||
|
siteUrl: '',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accessToken: encrypt(accessToken),
|
||||||
|
refreshToken: encrypt(refreshToken),
|
||||||
|
accessTokenExpiresAt,
|
||||||
|
lastSyncStatus: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.clearCookie('gsc_oauth_state');
|
||||||
|
reply.clearCookie('gsc_code_verifier');
|
||||||
|
reply.clearCookie('gsc_project_id');
|
||||||
|
|
||||||
|
const dashboardUrl =
|
||||||
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
|
||||||
|
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
|
||||||
|
return reply.redirect(redirectUrl);
|
||||||
|
} catch (error) {
|
||||||
|
req.log.error(error);
|
||||||
|
reply.clearCookie('gsc_oauth_state');
|
||||||
|
reply.clearCookie('gsc_code_verifier');
|
||||||
|
reply.clearCookie('gsc_project_id');
|
||||||
|
return redirectWithError(reply, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||||
|
const url = new URL(
|
||||||
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||||
|
);
|
||||||
|
url.pathname = '/login';
|
||||||
|
if (error instanceof LogError) {
|
||||||
|
url.searchParams.set('error', error.message);
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('error', 'Failed to connect Google Search Console');
|
||||||
|
}
|
||||||
|
url.searchParams.set('correlationId', reply.request.id);
|
||||||
|
return reply.redirect(url.toString());
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ 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';
|
||||||
@@ -194,6 +195,7 @@ const startServer = async () => {
|
|||||||
instance.register(liveRouter, { prefix: '/live' });
|
instance.register(liveRouter, { prefix: '/live' });
|
||||||
instance.register(webhookRouter, { prefix: '/webhook' });
|
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||||
instance.register(oauthRouter, { prefix: '/oauth' });
|
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||||
|
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||||
instance.register(miscRouter, { prefix: '/misc' });
|
instance.register(miscRouter, { prefix: '/misc' });
|
||||||
instance.register(aiRouter, { prefix: '/ai' });
|
instance.register(aiRouter, { prefix: '/ai' });
|
||||||
});
|
});
|
||||||
|
|||||||
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
|
||||||
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
|
const router: FastifyPluginCallback = async (fastify) => {
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/callback',
|
||||||
|
handler: gscGoogleCallback,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import { pushModal } from '@/modals';
|
|
||||||
import type {
|
import type {
|
||||||
IReport,
|
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
|
IReport,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { SaveIcon } from 'lucide-react';
|
import { SaveIcon } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ReportChart } from '../report-chart';
|
|
||||||
import { ReportChartType } from '../report/ReportChartType';
|
import { ReportChartType } from '../report/ReportChartType';
|
||||||
import { ReportInterval } from '../report/ReportInterval';
|
import { ReportInterval } from '../report/ReportInterval';
|
||||||
|
import { ReportChart } from '../report-chart';
|
||||||
import { TimeWindowPicker } from '../time-window-picker';
|
import { TimeWindowPicker } from '../time-window-picker';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
|
||||||
export function ChatReport({
|
export function ChatReport({
|
||||||
lazy,
|
lazy,
|
||||||
...props
|
...props
|
||||||
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
}: {
|
||||||
|
report: IReport & { startDate: string; endDate: string };
|
||||||
|
lazy: boolean;
|
||||||
|
}) {
|
||||||
const [chartType, setChartType] = useState<IChartType>(
|
const [chartType, setChartType] = useState<IChartType>(
|
||||||
props.report.chartType,
|
props.report.chartType
|
||||||
);
|
);
|
||||||
const [startDate, setStartDate] = useState<string>(props.report.startDate);
|
const [startDate, setStartDate] = useState<string>(props.report.startDate);
|
||||||
const [endDate, setEndDate] = useState<string>(props.report.endDate);
|
const [endDate, setEndDate] = useState<string>(props.report.endDate);
|
||||||
@@ -35,47 +38,48 @@ export function ChatReport({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="text-center text-sm font-mono font-medium pt-4">
|
<div className="pt-4 text-center font-medium font-mono text-sm">
|
||||||
{props.report.name}
|
{props.report.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ReportChart lazy={lazy} report={report} />
|
<ReportChart lazy={lazy} report={report} />
|
||||||
</div>
|
</div>
|
||||||
<div className="row justify-between gap-1 border-t border-border p-2">
|
<div className="row justify-between gap-1 border-border border-t p-2">
|
||||||
<div className="col md:row gap-1">
|
<div className="col md:row gap-1">
|
||||||
<TimeWindowPicker
|
<TimeWindowPicker
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
onChange={setRange}
|
|
||||||
value={report.range}
|
|
||||||
onStartDateChange={setStartDate}
|
|
||||||
onEndDateChange={setEndDate}
|
|
||||||
endDate={report.endDate}
|
endDate={report.endDate}
|
||||||
|
onChange={setRange}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
onIntervalChange={setInterval}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
startDate={report.startDate}
|
startDate={report.startDate}
|
||||||
|
value={report.range}
|
||||||
/>
|
/>
|
||||||
<ReportInterval
|
<ReportInterval
|
||||||
|
chartType={chartType}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
interval={interval}
|
interval={interval}
|
||||||
range={range}
|
|
||||||
chartType={chartType}
|
|
||||||
onChange={setInterval}
|
onChange={setInterval}
|
||||||
|
range={range}
|
||||||
/>
|
/>
|
||||||
<ReportChartType
|
<ReportChartType
|
||||||
value={chartType}
|
|
||||||
onChange={(type) => {
|
onChange={(type) => {
|
||||||
setChartType(type);
|
setChartType(type);
|
||||||
}}
|
}}
|
||||||
|
value={chartType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon={SaveIcon}
|
icon={SaveIcon}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pushModal('SaveReport', {
|
pushModal('SaveReport', {
|
||||||
report,
|
report,
|
||||||
disableRedirect: true,
|
disableRedirect: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
Save report
|
Save report
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
|||||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||||
|
|
||||||
export function OverviewRange() {
|
export function OverviewRange() {
|
||||||
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
|
const {
|
||||||
useOverviewOptions();
|
range,
|
||||||
|
setRange,
|
||||||
|
setStartDate,
|
||||||
|
setEndDate,
|
||||||
|
endDate,
|
||||||
|
startDate,
|
||||||
|
setInterval,
|
||||||
|
} = useOverviewOptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimeWindowPicker
|
<TimeWindowPicker
|
||||||
onChange={setRange}
|
|
||||||
value={range}
|
|
||||||
onStartDateChange={setStartDate}
|
|
||||||
onEndDateChange={setEndDate}
|
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
|
onChange={setRange}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
onIntervalChange={setInterval}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
|
value={range}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
143
apps/start/src/components/page/gsc-breakdown-table.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface GscBreakdownTableProps {
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
type: 'page' | 'query';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableProps) {
|
||||||
|
const { range, startDate, endDate } = useOverviewOptions();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const dateInput = {
|
||||||
|
range,
|
||||||
|
startDate: startDate ?? undefined,
|
||||||
|
endDate: endDate ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageQuery = useQuery(
|
||||||
|
trpc.gsc.getPageDetails.queryOptions(
|
||||||
|
{ projectId, page: value, ...dateInput },
|
||||||
|
{ enabled: type === 'page' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryQuery = useQuery(
|
||||||
|
trpc.gsc.getQueryDetails.queryOptions(
|
||||||
|
{ projectId, query: value, ...dateInput },
|
||||||
|
{ enabled: type === 'query' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||||
|
|
||||||
|
const breakdownRows: Record<string, string | number>[] =
|
||||||
|
type === 'page'
|
||||||
|
? ((pageQuery.data as { queries?: unknown[] } | undefined)?.queries ?? []) as Record<string, string | number>[]
|
||||||
|
: ((queryQuery.data as { pages?: unknown[] } | undefined)?.pages ?? []) as Record<string, string | number>[];
|
||||||
|
|
||||||
|
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||||
|
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||||
|
const pluralLabel = type === 'page' ? 'queries' : 'pages';
|
||||||
|
|
||||||
|
const maxClicks = Math.max(
|
||||||
|
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<h3 className="font-medium text-sm">Top {pluralLabel}</h3>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
data={[1, 2, 3, 4, 5]}
|
||||||
|
keyExtractor={(i) => String(i)}
|
||||||
|
getColumnPercentage={() => 0}
|
||||||
|
columns={[
|
||||||
|
{ name: breakdownLabel, width: 'w-full', render: () => <Skeleton className="h-4 w-2/3" /> },
|
||||||
|
{ name: 'Clicks', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||||
|
{ name: 'Impr.', width: '70px', render: () => <Skeleton className="h-4 w-10" /> },
|
||||||
|
{ name: 'CTR', width: '60px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||||
|
{ name: 'Pos.', width: '55px', render: () => <Skeleton className="h-4 w-8" /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
data={breakdownRows}
|
||||||
|
keyExtractor={(item) => String(item[breakdownKey])}
|
||||||
|
getColumnPercentage={(item) => (item.clicks as number) / maxClicks}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: breakdownLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<span className="block truncate font-mono text-xs">
|
||||||
|
{String(item[breakdownKey])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.clicks as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.clicks as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impr.',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.impressions as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.impressions as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CTR',
|
||||||
|
width: '60px',
|
||||||
|
getSortValue: (item) => item.ctr as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{((item.ctr as number) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
getSortValue: (item) => item.position as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.position as number).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
226
apps/start/src/components/page/gsc-cannibalization.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Pagination } from '@/components/pagination';
|
||||||
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
interface GscCannibalizationProps {
|
||||||
|
projectId: string;
|
||||||
|
range: IChartRange;
|
||||||
|
interval: IInterval;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GscCannibalization({
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}: GscCannibalizationProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { apiUrl } = useAppContext();
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pageSize = 15;
|
||||||
|
|
||||||
|
const query = useQuery(
|
||||||
|
trpc.gsc.getCannibalization.queryOptions(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
{ placeholderData: keepPreviousData }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = (q: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(q)) {
|
||||||
|
next.delete(q);
|
||||||
|
} else {
|
||||||
|
next.add(q);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = query.data ?? [];
|
||||||
|
|
||||||
|
const pageCount = Math.ceil(items.length / pageSize) || 1;
|
||||||
|
useEffect(() => {
|
||||||
|
setPage((p) => Math.max(0, Math.min(p, pageCount - 1)));
|
||||||
|
}, [items, pageSize, pageCount]);
|
||||||
|
const paginatedItems = useMemo(
|
||||||
|
() => items.slice(page * pageSize, (page + 1) * pageSize),
|
||||||
|
[items, page, pageSize]
|
||||||
|
);
|
||||||
|
const rangeStart = items.length ? page * pageSize + 1 : 0;
|
||||||
|
const rangeEnd = Math.min((page + 1) * pageSize, items.length);
|
||||||
|
|
||||||
|
if (!(query.isLoading || items.length)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-sm">Keyword Cannibalization</h3>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 font-medium text-muted-foreground text-xs">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||||
|
{items.length === 0
|
||||||
|
? '0 results'
|
||||||
|
: `${rangeStart}-${rangeEnd} of ${items.length}`}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={page < pageCount - 1}
|
||||||
|
canPreviousPage={page > 0}
|
||||||
|
nextPage={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
pageIndex={page}
|
||||||
|
previousPage={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{query.isLoading &&
|
||||||
|
[1, 2, 3].map((i) => (
|
||||||
|
<div className="space-y-2 p-4" key={i}>
|
||||||
|
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{paginatedItems.map((item) => {
|
||||||
|
const isOpen = expanded.has(item.query);
|
||||||
|
const avgCtr =
|
||||||
|
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.query}>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => toggle(item.query)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'row shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs',
|
||||||
|
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircleIcon className="size-3" />
|
||||||
|
{item.pages.length} pages
|
||||||
|
</div>
|
||||||
|
<span className="truncate font-medium text-sm">
|
||||||
|
{item.query}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-4">
|
||||||
|
<span className="whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||||
|
{item.totalImpressions.toLocaleString()} impr ·{' '}
|
||||||
|
{(avgCtr * 100).toFixed(1)}% avg CTR
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDownIcon
|
||||||
|
className={cn(
|
||||||
|
'size-3.5 text-muted-foreground transition-transform',
|
||||||
|
isOpen && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t bg-muted/20 px-4 py-3">
|
||||||
|
<p className="mb-3 text-muted-foreground text-xs leading-normal">
|
||||||
|
These pages all rank for{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
"{item.query}"
|
||||||
|
</span>
|
||||||
|
. Consider consolidating weaker pages into the top-ranking
|
||||||
|
one to concentrate link equity and avoid splitting clicks.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{item.pages.map((page, idx) => {
|
||||||
|
// Strip hash fragments — GSC sometimes returns heading
|
||||||
|
// anchor URLs (e.g. /page#section) as separate entries
|
||||||
|
let cleanUrl = page.page;
|
||||||
|
let origin = '';
|
||||||
|
let path = page.page;
|
||||||
|
try {
|
||||||
|
const u = new URL(page.page);
|
||||||
|
u.hash = '';
|
||||||
|
cleanUrl = u.toString();
|
||||||
|
origin = u.origin;
|
||||||
|
path = u.pathname + u.search;
|
||||||
|
} catch {
|
||||||
|
cleanUrl = page.page.split('#')[0] ?? page.page;
|
||||||
|
}
|
||||||
|
const isWinner = idx === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/60"
|
||||||
|
key={page.page}
|
||||||
|
onClick={() =>
|
||||||
|
pushModal('PageDetails', {
|
||||||
|
type: 'page',
|
||||||
|
projectId,
|
||||||
|
value: cleanUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="size-3.5 shrink-0 rounded-sm"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display =
|
||||||
|
'none';
|
||||||
|
}}
|
||||||
|
src={`${apiUrl}/misc/favicon?url=${origin}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
{path || page.page}
|
||||||
|
</span>
|
||||||
|
{isWinner && (
|
||||||
|
<span className="shrink-0 rounded bg-emerald-100 px-1 py-0.5 font-medium text-emerald-700 text-xs dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||||
|
#1
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 whitespace-nowrap font-mono text-muted-foreground text-xs">
|
||||||
|
pos {page.position.toFixed(1)} ·{' '}
|
||||||
|
{(page.ctr * 100).toFixed(1)}% CTR ·{' '}
|
||||||
|
{page.impressions.toLocaleString()} impr
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/start/src/components/pages/page-sparkline.tsx
Normal file
122
apps/start/src/components/pages/page-sparkline.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Tooltiper } from '../ui/tooltip';
|
||||||
|
import { LazyComponent } from '@/components/lazy-component';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
|
||||||
|
interface SparklineBarsProps {
|
||||||
|
data: { date: string; pageviews: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGap = 1;
|
||||||
|
const height = 24;
|
||||||
|
const width = 100;
|
||||||
|
|
||||||
|
function getTrendDirection(data: { pageviews: number }[]): '↑' | '↓' | '→' {
|
||||||
|
const n = data.length;
|
||||||
|
if (n < 3) {
|
||||||
|
return '→';
|
||||||
|
}
|
||||||
|
const third = Math.max(1, Math.floor(n / 3));
|
||||||
|
const firstAvg =
|
||||||
|
data.slice(0, third).reduce((s, d) => s + d.pageviews, 0) / third;
|
||||||
|
const lastAvg =
|
||||||
|
data.slice(n - third).reduce((s, d) => s + d.pageviews, 0) / third;
|
||||||
|
const threshold = firstAvg * 0.05;
|
||||||
|
if (lastAvg - firstAvg > threshold) {
|
||||||
|
return '↑';
|
||||||
|
}
|
||||||
|
if (firstAvg - lastAvg > threshold) {
|
||||||
|
return '↓';
|
||||||
|
}
|
||||||
|
return '→';
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparklineBars({ data }: SparklineBarsProps) {
|
||||||
|
if (!data.length) {
|
||||||
|
return <div style={{ height, width }} />;
|
||||||
|
}
|
||||||
|
const max = Math.max(...data.map((d) => d.pageviews), 1);
|
||||||
|
const total = data.length;
|
||||||
|
// Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible
|
||||||
|
let gap = defaultGap;
|
||||||
|
let barW = Math.floor((width - gap * (total - 1)) / total);
|
||||||
|
if (barW < 1 && total > 1) {
|
||||||
|
gap = 0;
|
||||||
|
barW = Math.floor((width - gap * (total - 1)) / total);
|
||||||
|
}
|
||||||
|
if (barW < 1) {
|
||||||
|
barW = 1;
|
||||||
|
}
|
||||||
|
const trend = getTrendDirection(data);
|
||||||
|
const trendColor =
|
||||||
|
trend === '↑'
|
||||||
|
? 'text-emerald-500'
|
||||||
|
: trend === '↓'
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-muted-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg className="shrink-0" height={height} width={width}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const barH = Math.max(
|
||||||
|
2,
|
||||||
|
Math.round((d.pageviews / max) * (height * 0.8))
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
className="fill-chart-0"
|
||||||
|
height={barH}
|
||||||
|
key={d.date}
|
||||||
|
rx="1"
|
||||||
|
width={barW}
|
||||||
|
x={i * (barW + gap)}
|
||||||
|
y={height - barH}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<Tooltiper
|
||||||
|
content={
|
||||||
|
trend === '↑'
|
||||||
|
? 'Upward trend'
|
||||||
|
: trend === '↓'
|
||||||
|
? 'Downward trend'
|
||||||
|
: 'Stable trend'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 font-medium text-xs ${trendColor}`}>
|
||||||
|
{trend}
|
||||||
|
</span>
|
||||||
|
</Tooltiper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageSparklineProps {
|
||||||
|
projectId: string;
|
||||||
|
origin: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSparkline({ projectId, origin, path }: PageSparklineProps) {
|
||||||
|
const { range, interval } = useOverviewOptions();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const query = useQuery(
|
||||||
|
trpc.event.pageTimeseries.queryOptions({
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
origin,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyComponent fallback={<div style={{ height, width }} />}>
|
||||||
|
<SparklineBars data={query.data ?? []} />
|
||||||
|
</LazyComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/start/src/components/pages/table/columns.tsx
Normal file
206
apps/start/src/components/pages/table/columns.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { ExternalLinkIcon } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { PageSparkline } from '@/components/pages/page-sparkline';
|
||||||
|
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
|
||||||
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
|
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
|
||||||
|
export type PageRow = RouterOutputs['event']['pages'][number] & {
|
||||||
|
gsc?: { clicks: number; impressions: number; ctr: number; position: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useColumns({
|
||||||
|
projectId,
|
||||||
|
isGscConnected,
|
||||||
|
previousMap,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
isGscConnected: boolean;
|
||||||
|
previousMap?: Map<string, number>;
|
||||||
|
}): ColumnDef<PageRow>[] {
|
||||||
|
const number = useNumber();
|
||||||
|
const { apiUrl } = useAppContext();
|
||||||
|
|
||||||
|
return useMemo<ColumnDef<PageRow>[]>(() => {
|
||||||
|
const cols: ColumnDef<PageRow>[] = [
|
||||||
|
{
|
||||||
|
id: 'page',
|
||||||
|
accessorFn: (row) => `${row.origin}${row.path} ${row.title ?? ''}`,
|
||||||
|
header: createHeaderColumn('Page'),
|
||||||
|
size: 400,
|
||||||
|
meta: { bold: true },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const page = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="size-4 shrink-0 rounded-sm"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
src={`${apiUrl}/misc/favicon?url=${page.origin}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{page.title && (
|
||||||
|
<div className="truncate font-medium text-sm leading-tight">
|
||||||
|
{page.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span className="truncate font-mono text-muted-foreground text-xs">
|
||||||
|
{page.path}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
|
||||||
|
href={page.origin + page.path}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-3 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trend',
|
||||||
|
header: 'Trend',
|
||||||
|
enableSorting: false,
|
||||||
|
size: 96,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PageSparkline
|
||||||
|
origin={row.original.origin}
|
||||||
|
path={row.original.path}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'pageviews',
|
||||||
|
header: createHeaderColumn('Views'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.pageviews)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'sessions',
|
||||||
|
header: createHeaderColumn('Sessions'),
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const prev = previousMap?.get(
|
||||||
|
row.original.origin + row.original.path
|
||||||
|
);
|
||||||
|
if (prev == null) {
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
if (prev === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.sessions)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">new</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = ((row.original.sessions - prev) / prev) * 100;
|
||||||
|
const isPos = pct >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.sessions)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||||
|
>
|
||||||
|
{isPos ? '+' : ''}
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'bounce_rate',
|
||||||
|
header: createHeaderColumn('Bounce'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{row.original.bounce_rate.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'avg_duration',
|
||||||
|
header: createHeaderColumn('Duration'),
|
||||||
|
size: 90,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="whitespace-nowrap font-mono text-sm tabular-nums">
|
||||||
|
{fancyMinutes(row.original.avg_duration)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isGscConnected) {
|
||||||
|
cols.push(
|
||||||
|
{
|
||||||
|
id: 'gsc_impressions',
|
||||||
|
accessorFn: (row) => row.gsc?.impressions ?? 0,
|
||||||
|
header: createHeaderColumn('Impr.'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.gsc ? (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.gsc.impressions)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gsc_ctr',
|
||||||
|
accessorFn: (row) => row.gsc?.ctr ?? 0,
|
||||||
|
header: createHeaderColumn('CTR'),
|
||||||
|
size: 70,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.gsc ? (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{(row.original.gsc.ctr * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gsc_clicks',
|
||||||
|
accessorFn: (row) => row.gsc?.clicks ?? 0,
|
||||||
|
header: createHeaderColumn('Clicks'),
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.gsc ? (
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{number.short(row.original.gsc.clicks)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [isGscConnected, number, apiUrl, projectId, previousMap]);
|
||||||
|
}
|
||||||
143
apps/start/src/components/pages/table/index.tsx
Normal file
143
apps/start/src/components/pages/table/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||||
|
import {
|
||||||
|
AnimatedSearchInput,
|
||||||
|
DataTableToolbarContainer,
|
||||||
|
} from '@/components/ui/data-table/data-table-toolbar';
|
||||||
|
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||||
|
import { useTable } from '@/components/ui/data-table/use-table';
|
||||||
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { type PageRow, useColumns } from './columns';
|
||||||
|
|
||||||
|
interface PagesTableProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PagesTable({ projectId }: PagesTableProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { range, interval, startDate, endDate } = useOverviewOptions();
|
||||||
|
const { debouncedSearch, setSearch, search } = useSearchQueryState();
|
||||||
|
|
||||||
|
const pagesQuery = useQuery(
|
||||||
|
trpc.event.pages.queryOptions(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
search: debouncedSearch ?? undefined,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
},
|
||||||
|
{ placeholderData: keepPreviousData },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectionQuery = useQuery(
|
||||||
|
trpc.gsc.getConnection.queryOptions({ projectId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isGscConnected = !!(connectionQuery.data?.siteUrl);
|
||||||
|
|
||||||
|
const gscPagesQuery = useQuery(
|
||||||
|
trpc.gsc.getPages.queryOptions(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
startDate: startDate ?? undefined,
|
||||||
|
endDate: endDate ?? undefined,
|
||||||
|
limit: 10_000,
|
||||||
|
},
|
||||||
|
{ enabled: isGscConnected },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousPagesQuery = useQuery(
|
||||||
|
trpc.event.previousPages.queryOptions(
|
||||||
|
{ projectId, range, interval },
|
||||||
|
{ placeholderData: keepPreviousData },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousMap = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const p of previousPagesQuery.data ?? []) {
|
||||||
|
map.set(p.origin + p.path, p.sessions);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [previousPagesQuery.data]);
|
||||||
|
|
||||||
|
const gscMap = useMemo(() => {
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{ clicks: number; impressions: number; ctr: number; position: number }
|
||||||
|
>();
|
||||||
|
for (const row of gscPagesQuery.data ?? []) {
|
||||||
|
map.set(row.page, {
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [gscPagesQuery.data]);
|
||||||
|
|
||||||
|
const rawData: PageRow[] = useMemo(() => {
|
||||||
|
return (pagesQuery.data ?? []).map((p) => ({
|
||||||
|
...p,
|
||||||
|
gsc: gscMap.get(p.origin + p.path),
|
||||||
|
}));
|
||||||
|
}, [pagesQuery.data, gscMap]);
|
||||||
|
|
||||||
|
const columns = useColumns({ projectId, isGscConnected, previousMap });
|
||||||
|
|
||||||
|
const { table } = useTable({
|
||||||
|
columns,
|
||||||
|
data: rawData,
|
||||||
|
loading: pagesQuery.isLoading,
|
||||||
|
pageSize: 50,
|
||||||
|
name: 'pages',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTableToolbarContainer>
|
||||||
|
<AnimatedSearchInput
|
||||||
|
placeholder="Search pages"
|
||||||
|
value={search ?? ''}
|
||||||
|
onChange={setSearch}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OverviewRange />
|
||||||
|
<OverviewInterval />
|
||||||
|
<DataTableViewOptions table={table} />
|
||||||
|
</div>
|
||||||
|
</DataTableToolbarContainer>
|
||||||
|
<DataTable
|
||||||
|
table={table}
|
||||||
|
loading={pagesQuery.isLoading}
|
||||||
|
empty={{
|
||||||
|
title: 'No pages',
|
||||||
|
description: debouncedSearch
|
||||||
|
? `No pages found matching "${debouncedSearch}"`
|
||||||
|
: 'Integrate our web SDK to your site to get pages here.',
|
||||||
|
}}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (!isGscConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = row.original;
|
||||||
|
pushModal('PageDetails', {
|
||||||
|
type: 'page',
|
||||||
|
projectId,
|
||||||
|
value: page.origin + page.path,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { ReportChart } from '@/components/report-chart';
|
import type { IServiceReport } from '@openpanel/db';
|
||||||
|
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import EditReportName from '../report/edit-report-name';
|
||||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||||
@@ -14,18 +17,13 @@ import {
|
|||||||
setReport,
|
setReport,
|
||||||
} from '@/components/report/reportSlice';
|
} from '@/components/report/reportSlice';
|
||||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||||
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { bind } from 'bind-event-listener';
|
|
||||||
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import type { IServiceReport } from '@openpanel/db';
|
|
||||||
import EditReportName from '../report/edit-report-name';
|
|
||||||
|
|
||||||
interface ReportEditorProps {
|
interface ReportEditorProps {
|
||||||
report: IServiceReport | null;
|
report: IServiceReport | null;
|
||||||
@@ -54,15 +52,15 @@ export default function ReportEditor({
|
|||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<div>
|
<div>
|
||||||
<div className="p-4 flex items-center justify-between">
|
<div className="flex items-center justify-between p-4">
|
||||||
<EditReportName />
|
<EditReportName />
|
||||||
{initialReport?.id && (
|
{initialReport?.id && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
icon={ShareIcon}
|
icon={ShareIcon}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
pushModal('ShareReportModal', { reportId: initialReport.id })
|
pushModal('ShareReportModal', { reportId: initialReport.id })
|
||||||
}
|
}
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
@@ -71,9 +69,9 @@ export default function ReportEditor({
|
|||||||
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
|
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
className="self-start"
|
||||||
icon={GanttChartSquareIcon}
|
icon={GanttChartSquareIcon}
|
||||||
variant="cta"
|
variant="cta"
|
||||||
className="self-start"
|
|
||||||
>
|
>
|
||||||
Pick events
|
Pick events
|
||||||
</Button>
|
</Button>
|
||||||
@@ -88,23 +86,26 @@ export default function ReportEditor({
|
|||||||
/>
|
/>
|
||||||
<TimeWindowPicker
|
<TimeWindowPicker
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
|
endDate={report.endDate}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
dispatch(changeDateRanges(value));
|
dispatch(changeDateRanges(value));
|
||||||
}}
|
}}
|
||||||
value={report.range}
|
|
||||||
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
|
||||||
onEndDateChange={(date) => dispatch(changeEndDate(date))}
|
onEndDateChange={(date) => dispatch(changeEndDate(date))}
|
||||||
endDate={report.endDate}
|
onIntervalChange={(interval) =>
|
||||||
|
dispatch(changeInterval(interval))
|
||||||
|
}
|
||||||
|
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
||||||
startDate={report.startDate}
|
startDate={report.startDate}
|
||||||
|
value={report.range}
|
||||||
/>
|
/>
|
||||||
<ReportInterval
|
<ReportInterval
|
||||||
|
chartType={report.chartType}
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
|
endDate={report.endDate}
|
||||||
interval={report.interval}
|
interval={report.interval}
|
||||||
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
||||||
range={report.range}
|
range={report.range}
|
||||||
chartType={report.chartType}
|
|
||||||
startDate={report.startDate}
|
startDate={report.startDate}
|
||||||
endDate={report.endDate}
|
|
||||||
/>
|
/>
|
||||||
<ReportLineType className="min-w-0 flex-1" />
|
<ReportLineType className="min-w-0 flex-1" />
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +115,7 @@ export default function ReportEditor({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
||||||
{report.ready && (
|
{report.ready && (
|
||||||
<ReportChart report={{ ...report, projectId }} isEditMode />
|
<ReportChart isEditMode report={{ ...report, projectId }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
LayoutPanelTopIcon,
|
LayoutPanelTopIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TrendingUpDownIcon,
|
TrendingUpDownIcon,
|
||||||
UndoDotIcon,
|
UndoDotIcon,
|
||||||
|
UserCircleIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
WallpaperIcon,
|
WallpaperIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -55,10 +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" />
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { timeWindows } from '@openpanel/constants';
|
||||||
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
|
import { bind } from 'bind-event-listener';
|
||||||
|
import { endOfDay, format, startOfDay } from 'date-fns';
|
||||||
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,24 +17,18 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { pushModal, useOnPushModal } from '@/modals';
|
import { pushModal, useOnPushModal } from '@/modals';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { bind } from 'bind-event-listener';
|
|
||||||
import { CalendarIcon } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress';
|
import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress';
|
||||||
import { timeWindows } from '@openpanel/constants';
|
|
||||||
import type { IChartRange } from '@openpanel/validation';
|
|
||||||
import { endOfDay, format, startOfDay } from 'date-fns';
|
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
value: IChartRange;
|
value: IChartRange;
|
||||||
onChange: (value: IChartRange) => void;
|
onChange: (value: IChartRange) => void;
|
||||||
onStartDateChange: (date: string) => void;
|
onStartDateChange: (date: string) => void;
|
||||||
onEndDateChange: (date: string) => void;
|
onEndDateChange: (date: string) => void;
|
||||||
|
onIntervalChange: (interval: IInterval) => void;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
export function TimeWindowPicker({
|
export function TimeWindowPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -36,6 +36,7 @@ export function TimeWindowPicker({
|
|||||||
onStartDateChange,
|
onStartDateChange,
|
||||||
endDate,
|
endDate,
|
||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
|
onIntervalChange,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const isDateRangerPickerOpen = useRef(false);
|
const isDateRangerPickerOpen = useRef(false);
|
||||||
@@ -46,10 +47,11 @@ export function TimeWindowPicker({
|
|||||||
|
|
||||||
const handleCustom = useCallback(() => {
|
const handleCustom = useCallback(() => {
|
||||||
pushModal('DateRangerPicker', {
|
pushModal('DateRangerPicker', {
|
||||||
onChange: ({ startDate, endDate }) => {
|
onChange: ({ startDate, endDate, interval }) => {
|
||||||
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
|
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
|
||||||
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
|
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
|
||||||
onChange('custom');
|
onChange('custom');
|
||||||
|
onIntervalChange(interval);
|
||||||
},
|
},
|
||||||
startDate: startDate ? new Date(startDate) : undefined,
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
endDate: endDate ? new Date(endDate) : undefined,
|
endDate: endDate ? new Date(endDate) : undefined,
|
||||||
@@ -69,7 +71,7 @@ export function TimeWindowPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const match = Object.values(timeWindows).find(
|
const match = Object.values(timeWindows).find(
|
||||||
(tw) => event.key === tw.shortcut.toLowerCase(),
|
(tw) => event.key === tw.shortcut.toLowerCase()
|
||||||
);
|
);
|
||||||
if (match?.key === 'custom') {
|
if (match?.key === 'custom') {
|
||||||
handleCustom();
|
handleCustom();
|
||||||
@@ -84,9 +86,9 @@ export function TimeWindowPicker({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
icon={CalendarIcon}
|
|
||||||
className={cn('justify-start', className)}
|
className={cn('justify-start', className)}
|
||||||
|
icon={CalendarIcon}
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{timeWindow?.label}
|
{timeWindow?.label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
DayPicker,
|
DayPicker,
|
||||||
getDefaultClassNames,
|
getDefaultClassNames,
|
||||||
} from 'react-day-picker';
|
} from 'react-day-picker';
|
||||||
|
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -29,99 +28,93 @@ function Calendar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
captionLayout={captionLayout}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
'group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
captionLayout={captionLayout}
|
|
||||||
formatters={{
|
|
||||||
formatMonthDropdown: (date) =>
|
|
||||||
date.toLocaleString('default', { month: 'short' }),
|
|
||||||
...formatters,
|
|
||||||
}}
|
|
||||||
classNames={{
|
classNames={{
|
||||||
root: cn('w-fit', defaultClassNames.root),
|
root: cn('w-fit', defaultClassNames.root),
|
||||||
months: cn(
|
months: cn(
|
||||||
'flex gap-4 flex-col sm:flex-row relative',
|
'relative flex flex-col gap-4 sm:flex-row',
|
||||||
defaultClassNames.months,
|
defaultClassNames.months
|
||||||
),
|
),
|
||||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||||
nav: cn(
|
nav: cn(
|
||||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||||
defaultClassNames.nav,
|
defaultClassNames.nav
|
||||||
),
|
),
|
||||||
button_previous: cn(
|
button_previous: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
|
||||||
defaultClassNames.button_previous,
|
defaultClassNames.button_previous
|
||||||
),
|
),
|
||||||
button_next: cn(
|
button_next: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
|
||||||
defaultClassNames.button_next,
|
defaultClassNames.button_next
|
||||||
),
|
),
|
||||||
month_caption: cn(
|
month_caption: cn(
|
||||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||||
defaultClassNames.month_caption,
|
defaultClassNames.month_caption
|
||||||
),
|
),
|
||||||
dropdowns: cn(
|
dropdowns: cn(
|
||||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
|
||||||
defaultClassNames.dropdowns,
|
defaultClassNames.dropdowns
|
||||||
),
|
),
|
||||||
dropdown_root: cn(
|
dropdown_root: cn(
|
||||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||||
defaultClassNames.dropdown_root,
|
defaultClassNames.dropdown_root
|
||||||
),
|
),
|
||||||
dropdown: cn(
|
dropdown: cn(
|
||||||
'absolute bg-popover inset-0 opacity-0',
|
'absolute inset-0 bg-popover opacity-0',
|
||||||
defaultClassNames.dropdown,
|
defaultClassNames.dropdown
|
||||||
),
|
),
|
||||||
caption_label: cn(
|
caption_label: cn(
|
||||||
'select-none font-medium',
|
'select-none font-medium',
|
||||||
captionLayout === 'label'
|
captionLayout === 'label'
|
||||||
? 'text-sm'
|
? 'text-sm'
|
||||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||||
defaultClassNames.caption_label,
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: 'w-full border-collapse',
|
table: 'w-full border-collapse',
|
||||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
|
||||||
defaultClassNames.weekday,
|
defaultClassNames.weekday
|
||||||
),
|
),
|
||||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||||
week_number_header: cn(
|
week_number_header: cn(
|
||||||
'select-none w-(--cell-size)',
|
'w-(--cell-size) select-none',
|
||||||
defaultClassNames.week_number_header,
|
defaultClassNames.week_number_header
|
||||||
),
|
),
|
||||||
week_number: cn(
|
week_number: cn(
|
||||||
'text-[0.8rem] select-none text-muted-foreground',
|
'select-none text-[0.8rem] text-muted-foreground',
|
||||||
defaultClassNames.week_number,
|
defaultClassNames.week_number
|
||||||
),
|
),
|
||||||
day: cn(
|
day: cn(
|
||||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||||
defaultClassNames.day,
|
defaultClassNames.day
|
||||||
),
|
),
|
||||||
range_start: cn(
|
range_start: cn(
|
||||||
'rounded-l-md bg-accent',
|
'rounded-l-md bg-accent',
|
||||||
defaultClassNames.range_start,
|
defaultClassNames.range_start
|
||||||
),
|
),
|
||||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
|
||||||
defaultClassNames.today,
|
defaultClassNames.today
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||||
defaultClassNames.outside,
|
defaultClassNames.outside
|
||||||
),
|
),
|
||||||
disabled: cn(
|
disabled: cn(
|
||||||
'text-muted-foreground opacity-50',
|
'text-muted-foreground opacity-50',
|
||||||
defaultClassNames.disabled,
|
defaultClassNames.disabled
|
||||||
),
|
),
|
||||||
hidden: cn('invisible', defaultClassNames.hidden),
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
...classNames,
|
...classNames,
|
||||||
@@ -130,9 +123,9 @@ function Calendar({
|
|||||||
Root: ({ className, rootRef, ...props }) => {
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn(className)}
|
||||||
data-slot="calendar"
|
data-slot="calendar"
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -169,6 +162,12 @@ function Calendar({
|
|||||||
},
|
},
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString('default', { month: 'short' }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -184,29 +183,31 @@ function CalendarDayButton({
|
|||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null);
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (modifiers.focused) ref.current?.focus();
|
if (modifiers.focused) {
|
||||||
|
ref.current?.focus();
|
||||||
|
}
|
||||||
}, [modifiers.focused]);
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
className={cn(
|
||||||
variant="ghost"
|
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||||
size="icon"
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
data-day={day.date.toLocaleDateString()}
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
data-selected-single={
|
data-selected-single={
|
||||||
modifiers.selected &&
|
modifiers.selected &&
|
||||||
!modifiers.range_start &&
|
!modifiers.range_start &&
|
||||||
!modifiers.range_end &&
|
!modifiers.range_end &&
|
||||||
!modifiers.range_middle
|
!modifiers.range_middle
|
||||||
}
|
}
|
||||||
data-range-start={modifiers.range_start}
|
ref={ref}
|
||||||
data-range-end={modifiers.range_end}
|
size="icon"
|
||||||
data-range-middle={modifiers.range_middle}
|
variant="ghost"
|
||||||
className={cn(
|
|
||||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
|
||||||
defaultClassNames.day,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
|
import { getDefaultIntervalByDates } from '@openpanel/constants';
|
||||||
|
import type { IInterval } from '@openpanel/validation';
|
||||||
|
import { endOfDay, subMonths } from 'date-fns';
|
||||||
|
import { CheckIcon, XIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent } from './Modal/Container';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||||
import { subMonths } from 'date-fns';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
import { CheckIcon, XIcon } from 'lucide-react';
|
|
||||||
import { popModal } from '.';
|
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
onChange: (payload: { startDate: Date; endDate: Date }) => void;
|
onChange: (payload: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
interval: IInterval;
|
||||||
|
}) => void;
|
||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
};
|
}
|
||||||
export default function DateRangerPicker({
|
export default function DateRangerPicker({
|
||||||
onChange,
|
onChange,
|
||||||
startDate: initialStartDate,
|
startDate: initialStartDate,
|
||||||
@@ -25,20 +29,20 @@ export default function DateRangerPicker({
|
|||||||
const [endDate, setEndDate] = useState(initialEndDate);
|
const [endDate, setEndDate] = useState(initialEndDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent className="p-4 md:p-8 min-w-fit">
|
<ModalContent className="min-w-fit p-4 md:p-8">
|
||||||
<Calendar
|
<Calendar
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
initialFocus
|
className="mx-auto min-h-[310px] p-0 [&_table]:mx-auto [&_table]:w-auto"
|
||||||
mode="range"
|
|
||||||
defaultMonth={subMonths(
|
defaultMonth={subMonths(
|
||||||
startDate ? new Date(startDate) : new Date(),
|
startDate ? new Date(startDate) : new Date(),
|
||||||
isBelowSm ? 0 : 1,
|
isBelowSm ? 0 : 1
|
||||||
)}
|
)}
|
||||||
selected={{
|
hidden={{
|
||||||
from: startDate,
|
after: endOfDay(new Date()),
|
||||||
to: endDate,
|
|
||||||
}}
|
}}
|
||||||
toDate={new Date()}
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
numberOfMonths={isBelowSm ? 1 : 2}
|
||||||
onSelect={(range) => {
|
onSelect={(range) => {
|
||||||
if (range?.from) {
|
if (range?.from) {
|
||||||
setStartDate(range.from);
|
setStartDate(range.from);
|
||||||
@@ -47,33 +51,39 @@ export default function DateRangerPicker({
|
|||||||
setEndDate(range.to);
|
setEndDate(range.to);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
numberOfMonths={isBelowSm ? 1 : 2}
|
selected={{
|
||||||
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0"
|
from: startDate,
|
||||||
|
to: endDate,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="col flex-col-reverse md:row gap-2">
|
<div className="col md:row flex-col-reverse gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
icon={XIcon}
|
||||||
|
onClick={() => popModal()}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => popModal()}
|
|
||||||
icon={XIcon}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{startDate && endDate && (
|
{startDate && endDate && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
className="md:ml-auto"
|
className="md:ml-auto"
|
||||||
|
icon={startDate && endDate ? CheckIcon : XIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
popModal();
|
popModal();
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
onChange({
|
onChange({
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
|
interval: getDefaultIntervalByDates(
|
||||||
|
startDate.toISOString(),
|
||||||
|
endDate.toISOString()
|
||||||
|
)!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
icon={startDate && endDate ? CheckIcon : XIcon}
|
type="button"
|
||||||
>
|
>
|
||||||
{startDate && endDate
|
{startDate && endDate
|
||||||
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`
|
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`
|
||||||
|
|||||||
440
apps/start/src/modals/gsc-details.tsx
Normal file
440
apps/start/src/modals/gsc-details.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
|
interface GscChartData {
|
||||||
|
date: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
}
|
||||||
|
interface GscViewsChartData {
|
||||||
|
date: string;
|
||||||
|
views: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||||
|
GscChartData | GscViewsChartData,
|
||||||
|
Record<string, unknown>
|
||||||
|
>(({ data }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!('date' in item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ('views' in item && item.views != null) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{item.date}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Views</span>
|
||||||
|
<span>{item.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clicks = 'clicks' in item ? item.clicks : undefined;
|
||||||
|
const impressions = 'impressions' in item ? item.impressions : undefined;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{item.date}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
{clicks != null && (
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Clicks</span>
|
||||||
|
<span>{clicks.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
)}
|
||||||
|
{impressions != null && (
|
||||||
|
<ChartTooltipItem color={getChartColor(1)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Impressions</span>
|
||||||
|
<span>{impressions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props =
|
||||||
|
| {
|
||||||
|
type: 'page';
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
range: IChartRange;
|
||||||
|
interval: IInterval;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'query';
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
range: IChartRange;
|
||||||
|
interval: IInterval;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GscDetails(props: Props) {
|
||||||
|
const { type, projectId, value, range, interval } = props;
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const dateInput = {
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageQuery = useQuery(
|
||||||
|
trpc.gsc.getPageDetails.queryOptions(
|
||||||
|
{ projectId, page: value, ...dateInput },
|
||||||
|
{ enabled: type === 'page' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryQuery = useQuery(
|
||||||
|
trpc.gsc.getQueryDetails.queryOptions(
|
||||||
|
{ projectId, query: value, ...dateInput },
|
||||||
|
{ enabled: type === 'query' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { origin: pageOrigin, path: pagePath } =
|
||||||
|
type === 'page'
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return { origin: url.origin, path: url.pathname + url.search };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
||||||
|
path: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: { origin: '', path: '' };
|
||||||
|
|
||||||
|
const pageTimeseriesQuery = useQuery(
|
||||||
|
trpc.event.pageTimeseries.queryOptions(
|
||||||
|
{ projectId, ...dateInput, origin: pageOrigin, path: pagePath },
|
||||||
|
{ enabled: type === 'page' && !!pagePath }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = type === 'page' ? pageQuery.data : queryQuery.data;
|
||||||
|
const isLoading =
|
||||||
|
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||||
|
|
||||||
|
const timeseries = data?.timeseries ?? [];
|
||||||
|
const pageTimeseries = pageTimeseriesQuery.data ?? [];
|
||||||
|
const breakdownRows =
|
||||||
|
type === 'page'
|
||||||
|
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
|
||||||
|
: ((data as { pages?: unknown[] } | undefined)?.pages ?? []);
|
||||||
|
|
||||||
|
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||||
|
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||||
|
|
||||||
|
const maxClicks = Math.max(
|
||||||
|
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
|
||||||
|
{value}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="col gap-6">
|
||||||
|
{type === 'page' && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
|
||||||
|
{isLoading || pageTimeseriesQuery.isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<GscViewsChart
|
||||||
|
data={pageTimeseries.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
views: r.pageviews,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<GscTimeseriesChart data={timeseries} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
Top {breakdownLabel.toLowerCase()}s
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: breakdownLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
render: () => <Skeleton className="h-4 w-10" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
render: () => <Skeleton className="h-4 w-8" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={[1, 2, 3, 4, 5]}
|
||||||
|
getColumnPercentage={() => 0}
|
||||||
|
keyExtractor={(i) => String(i)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewWidgetTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: breakdownLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<span className="block truncate font-mono text-xs">
|
||||||
|
{String(item[breakdownKey])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.clicks as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.clicks as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impr.',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.impressions as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.impressions as number).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CTR',
|
||||||
|
width: '60px',
|
||||||
|
getSortValue: (item) => item.ctr as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{((item.ctr as number) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
getSortValue: (item) => item.position as number,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.position as number).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={breakdownRows as Record<string, string | number>[]}
|
||||||
|
getColumnPercentage={(item) =>
|
||||||
|
(item.clicks as number) / maxClicks
|
||||||
|
}
|
||||||
|
keyExtractor={(item) => String(item[breakdownKey])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GscViewsChart({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Array<{ date: string; views: number }>;
|
||||||
|
}) {
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-detail-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<GscTooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="views"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-detail-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GscTimeseriesChart({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Array<{ date: string; clicks: number; impressions: number }>;
|
||||||
|
}) {
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<ResponsiveContainer height={160} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-detail-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<GscTooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="clicks"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-detail-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="impressions"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-detail-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(1)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
49
apps/start/src/modals/page-details.tsx
Normal file
49
apps/start/src/modals/page-details.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { GscBreakdownTable } from '@/components/page/gsc-breakdown-table';
|
||||||
|
import { GscClicksChart } from '@/components/page/gsc-clicks-chart';
|
||||||
|
import { PageViewsChart } from '@/components/page/page-views-chart';
|
||||||
|
import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
type: 'page' | 'query';
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageDetails({ type, projectId, value }: Props) {
|
||||||
|
return (
|
||||||
|
<SheetContent className="flex flex-col gap-6 overflow-y-auto sm:max-w-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="truncate pr-8 font-medium font-mono text-sm">
|
||||||
|
{value}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="col gap-6">
|
||||||
|
{type === 'page' &&
|
||||||
|
(() => {
|
||||||
|
let origin: string;
|
||||||
|
let path: string;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
origin = url.origin;
|
||||||
|
path = url.pathname + url.search;
|
||||||
|
} catch {
|
||||||
|
// value is path-only (e.g. "/docs/foo")
|
||||||
|
origin =
|
||||||
|
typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
path = value;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PageViewsChart
|
||||||
|
origin={origin}
|
||||||
|
path={path}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<GscClicksChart projectId={projectId} type={type} value={value} />
|
||||||
|
<GscBreakdownTable projectId={projectId} type={type} value={value} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
|
|||||||
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
||||||
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
||||||
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
||||||
|
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
|
||||||
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
|
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
|
||||||
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
||||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||||
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
|
|||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
||||||
|
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
|
||||||
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
|
|||||||
path: '/sessions',
|
path: '/sessions',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdSeoRoute =
|
||||||
|
AppOrganizationIdProjectIdSeoRouteImport.update({
|
||||||
|
id: '/seo',
|
||||||
|
path: '/seo',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdReportsRoute =
|
const AppOrganizationIdProjectIdReportsRoute =
|
||||||
AppOrganizationIdProjectIdReportsRouteImport.update({
|
AppOrganizationIdProjectIdReportsRouteImport.update({
|
||||||
id: '/reports',
|
id: '/reports',
|
||||||
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
|||||||
path: '/imports',
|
path: '/imports',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
|
||||||
|
id: '/gsc',
|
||||||
|
path: '/gsc',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
|
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
|
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
|
||||||
id: '/events',
|
id: '/events',
|
||||||
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||||
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||||
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -747,6 +765,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
|
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||||
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||||
@@ -789,6 +808,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/realtime'
|
| '/$organizationId/$projectId/realtime'
|
||||||
| '/$organizationId/$projectId/references'
|
| '/$organizationId/$projectId/references'
|
||||||
| '/$organizationId/$projectId/reports'
|
| '/$organizationId/$projectId/reports'
|
||||||
|
| '/$organizationId/$projectId/seo'
|
||||||
| '/$organizationId/$projectId/sessions'
|
| '/$organizationId/$projectId/sessions'
|
||||||
| '/$organizationId/integrations'
|
| '/$organizationId/integrations'
|
||||||
| '/$organizationId/members'
|
| '/$organizationId/members'
|
||||||
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/clients'
|
| '/$organizationId/$projectId/settings/clients'
|
||||||
| '/$organizationId/$projectId/settings/details'
|
| '/$organizationId/$projectId/settings/details'
|
||||||
| '/$organizationId/$projectId/settings/events'
|
| '/$organizationId/$projectId/settings/events'
|
||||||
|
| '/$organizationId/$projectId/settings/gsc'
|
||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/tracking'
|
| '/$organizationId/$projectId/settings/tracking'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/realtime'
|
| '/$organizationId/$projectId/realtime'
|
||||||
| '/$organizationId/$projectId/references'
|
| '/$organizationId/$projectId/references'
|
||||||
| '/$organizationId/$projectId/reports'
|
| '/$organizationId/$projectId/reports'
|
||||||
|
| '/$organizationId/$projectId/seo'
|
||||||
| '/$organizationId/$projectId/sessions'
|
| '/$organizationId/$projectId/sessions'
|
||||||
| '/$organizationId/integrations'
|
| '/$organizationId/integrations'
|
||||||
| '/$organizationId/members'
|
| '/$organizationId/members'
|
||||||
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/clients'
|
| '/$organizationId/$projectId/settings/clients'
|
||||||
| '/$organizationId/$projectId/settings/details'
|
| '/$organizationId/$projectId/settings/details'
|
||||||
| '/$organizationId/$projectId/settings/events'
|
| '/$organizationId/$projectId/settings/events'
|
||||||
|
| '/$organizationId/$projectId/settings/gsc'
|
||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/tracking'
|
| '/$organizationId/$projectId/settings/tracking'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/realtime'
|
| '/_app/$organizationId/$projectId/realtime'
|
||||||
| '/_app/$organizationId/$projectId/references'
|
| '/_app/$organizationId/$projectId/references'
|
||||||
| '/_app/$organizationId/$projectId/reports'
|
| '/_app/$organizationId/$projectId/reports'
|
||||||
|
| '/_app/$organizationId/$projectId/seo'
|
||||||
| '/_app/$organizationId/$projectId/sessions'
|
| '/_app/$organizationId/$projectId/sessions'
|
||||||
| '/_app/$organizationId/integrations'
|
| '/_app/$organizationId/integrations'
|
||||||
| '/_app/$organizationId/integrations/_tabs'
|
| '/_app/$organizationId/integrations/_tabs'
|
||||||
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
|
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||||
|
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
|
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||||
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/seo': {
|
||||||
|
id: '/_app/$organizationId/$projectId/seo'
|
||||||
|
path: '/seo'
|
||||||
|
fullPath: '/$organizationId/$projectId/seo'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/reports': {
|
'/_app/$organizationId/$projectId/reports': {
|
||||||
id: '/_app/$organizationId/$projectId/reports'
|
id: '/_app/$organizationId/$projectId/reports'
|
||||||
path: '/reports'
|
path: '/reports'
|
||||||
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
|
||||||
|
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||||
|
path: '/gsc'
|
||||||
|
fullPath: '/$organizationId/$projectId/settings/gsc'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/events': {
|
'/_app/$organizationId/$projectId/settings/_tabs/events': {
|
||||||
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
|
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||||
path: '/events'
|
path: '/events'
|
||||||
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
|
|||||||
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
|
|||||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
|
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRoute:
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
|
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
|
||||||
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
|||||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
|
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
|
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
||||||
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||||
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
|||||||
AppOrganizationIdProjectIdReferencesRoute,
|
AppOrganizationIdProjectIdReferencesRoute,
|
||||||
AppOrganizationIdProjectIdReportsRoute:
|
AppOrganizationIdProjectIdReportsRoute:
|
||||||
AppOrganizationIdProjectIdReportsRoute,
|
AppOrganizationIdProjectIdReportsRoute,
|
||||||
|
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
|
||||||
AppOrganizationIdProjectIdSessionsRoute:
|
AppOrganizationIdProjectIdSessionsRoute:
|
||||||
AppOrganizationIdProjectIdSessionsRoute,
|
AppOrganizationIdProjectIdSessionsRoute,
|
||||||
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,
|
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
@@ -0,0 +1,821 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { SearchIcon } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
|
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||||
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
|
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { GscCannibalization } from '@/components/page/gsc-cannibalization';
|
||||||
|
import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark';
|
||||||
|
import { GscPositionChart } from '@/components/page/gsc-position-chart';
|
||||||
|
import { PagesInsights } from '@/components/page/pages-insights';
|
||||||
|
import { PageContainer } from '@/components/page-container';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Pagination } from '@/components/pagination';
|
||||||
|
import {
|
||||||
|
useYAxisProps,
|
||||||
|
X_AXIS_STYLE_PROPS,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import { createProjectTitle } from '@/utils/title';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({
|
||||||
|
component: SeoPage,
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ title: createProjectTitle('SEO') }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GscChartData {
|
||||||
|
date: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||||
|
GscChartData,
|
||||||
|
Record<string, unknown>
|
||||||
|
>(({ data }) => {
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{item.date}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Clicks</span>
|
||||||
|
<span>{item.clicks.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
<ChartTooltipItem color={getChartColor(1)}>
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span>Impressions</span>
|
||||||
|
<span>{item.impressions.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function SeoPage() {
|
||||||
|
const { projectId, organizationId } = useAppParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
|
const dateInput = {
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionQuery = useQuery(
|
||||||
|
trpc.gsc.getConnection.queryOptions({ projectId })
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = connectionQuery.data;
|
||||||
|
const isConnected = connection?.siteUrl;
|
||||||
|
|
||||||
|
const overviewQuery = useQuery(
|
||||||
|
trpc.gsc.getOverview.queryOptions(
|
||||||
|
{ projectId, ...dateInput, interval: interval ?? 'day' },
|
||||||
|
{ enabled: !!isConnected }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagesQuery = useQuery(
|
||||||
|
trpc.gsc.getPages.queryOptions(
|
||||||
|
{ projectId, ...dateInput, limit: 50 },
|
||||||
|
{ enabled: !!isConnected }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const queriesQuery = useQuery(
|
||||||
|
trpc.gsc.getQueries.queryOptions(
|
||||||
|
{ projectId, ...dateInput, limit: 50 },
|
||||||
|
{ enabled: !!isConnected }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchEnginesQuery = useQuery(
|
||||||
|
trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput })
|
||||||
|
);
|
||||||
|
|
||||||
|
const aiEnginesQuery = useQuery(
|
||||||
|
trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput })
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousOverviewQuery = useQuery(
|
||||||
|
trpc.gsc.getPreviousOverview.queryOptions(
|
||||||
|
{ projectId, ...dateInput, interval: interval ?? 'day' },
|
||||||
|
{ enabled: !!isConnected }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pagesPage, setPagesPage] = useState(0);
|
||||||
|
const [queriesPage, setQueriesPage] = useState(0);
|
||||||
|
const pageSize = 15;
|
||||||
|
|
||||||
|
const [pagesSearch, setPagesSearch] = useState('');
|
||||||
|
const [queriesSearch, setQueriesSearch] = useState('');
|
||||||
|
|
||||||
|
const pages = pagesQuery.data ?? [];
|
||||||
|
const queries = queriesQuery.data ?? [];
|
||||||
|
|
||||||
|
const filteredPages = useMemo(() => {
|
||||||
|
if (!pagesSearch.trim()) {
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
const q = pagesSearch.toLowerCase();
|
||||||
|
return pages.filter((row) => {
|
||||||
|
return String(row.page).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [pages, pagesSearch]);
|
||||||
|
|
||||||
|
const filteredQueries = useMemo(() => {
|
||||||
|
if (!queriesSearch.trim()) {
|
||||||
|
return queries;
|
||||||
|
}
|
||||||
|
const q = queriesSearch.toLowerCase();
|
||||||
|
return queries.filter((row) => {
|
||||||
|
return String(row.query).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [queries, queriesSearch]);
|
||||||
|
|
||||||
|
const paginatedPages = useMemo(
|
||||||
|
() => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize),
|
||||||
|
[filteredPages, pagesPage, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginatedQueries = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredQueries.slice(
|
||||||
|
queriesPage * pageSize,
|
||||||
|
(queriesPage + 1) * pageSize
|
||||||
|
),
|
||||||
|
[filteredQueries, queriesPage, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1;
|
||||||
|
const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1;
|
||||||
|
|
||||||
|
if (connectionQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader description="Google Search Console data" title="SEO" />
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<FullPageEmptyState
|
||||||
|
className="pt-[20vh]"
|
||||||
|
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
|
||||||
|
icon={SearchIcon}
|
||||||
|
title="No SEO data yet"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/$organizationId/$projectId/settings/gsc',
|
||||||
|
params: { organizationId, projectId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Connect Google Search Console
|
||||||
|
</Button>
|
||||||
|
</FullPageEmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = overviewQuery.data ?? [];
|
||||||
|
const prevOverview = previousOverviewQuery.data ?? [];
|
||||||
|
|
||||||
|
const sumOverview = (rows: typeof overview) =>
|
||||||
|
rows.reduce(
|
||||||
|
(acc, row) => ({
|
||||||
|
clicks: acc.clicks + row.clicks,
|
||||||
|
impressions: acc.impressions + row.impressions,
|
||||||
|
ctr: acc.ctr + row.ctr,
|
||||||
|
position: acc.position + row.position,
|
||||||
|
}),
|
||||||
|
{ clicks: 0, impressions: 0, ctr: 0, position: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = sumOverview(overview);
|
||||||
|
const prevTotals = sumOverview(prevOverview);
|
||||||
|
const n = Math.max(overview.length, 1);
|
||||||
|
const pn = Math.max(prevOverview.length, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<OverviewRange />
|
||||||
|
<OverviewInterval />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={`Search performance for ${connection.siteUrl}`}
|
||||||
|
title="SEO"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-8">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||||
|
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
|
||||||
|
id="clicks"
|
||||||
|
isLoading={overviewQuery.isLoading}
|
||||||
|
label="Clicks"
|
||||||
|
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
|
||||||
|
/>
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={overview.map((r) => ({
|
||||||
|
current: r.impressions,
|
||||||
|
date: r.date,
|
||||||
|
}))}
|
||||||
|
id="impressions"
|
||||||
|
isLoading={overviewQuery.isLoading}
|
||||||
|
label="Impressions"
|
||||||
|
metric={{
|
||||||
|
current: totals.impressions,
|
||||||
|
previous: prevTotals.impressions,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={overview.map((r) => ({
|
||||||
|
current: r.ctr * 100,
|
||||||
|
date: r.date,
|
||||||
|
}))}
|
||||||
|
id="ctr"
|
||||||
|
isLoading={overviewQuery.isLoading}
|
||||||
|
label="Avg CTR"
|
||||||
|
metric={{
|
||||||
|
current: (totals.ctr / n) * 100,
|
||||||
|
previous: (prevTotals.ctr / pn) * 100,
|
||||||
|
}}
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
<OverviewMetricCard
|
||||||
|
data={overview.map((r) => ({
|
||||||
|
current: r.position,
|
||||||
|
date: r.date,
|
||||||
|
}))}
|
||||||
|
id="position"
|
||||||
|
inverted
|
||||||
|
isLoading={overviewQuery.isLoading}
|
||||||
|
label="Avg Position"
|
||||||
|
metric={{
|
||||||
|
current: totals.position / n,
|
||||||
|
previous: prevTotals.position / pn,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SearchEngines
|
||||||
|
engines={searchEnginesQuery.data?.engines ?? []}
|
||||||
|
isLoading={searchEnginesQuery.isLoading}
|
||||||
|
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
|
||||||
|
total={searchEnginesQuery.data?.total ?? 0}
|
||||||
|
/>
|
||||||
|
<AiEngines
|
||||||
|
engines={aiEnginesQuery.data?.engines ?? []}
|
||||||
|
isLoading={aiEnginesQuery.isLoading}
|
||||||
|
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
|
||||||
|
total={aiEnginesQuery.data?.total ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<GscPositionChart
|
||||||
|
data={overview}
|
||||||
|
isLoading={overviewQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<GscCtrBenchmark
|
||||||
|
data={pagesQuery.data ?? []}
|
||||||
|
isLoading={pagesQuery.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||||
|
<GscTable
|
||||||
|
isLoading={pagesQuery.isLoading}
|
||||||
|
keyField="page"
|
||||||
|
keyLabel="Page"
|
||||||
|
maxClicks={Math.max(...paginatedPages.map((p) => p.clicks), 1)}
|
||||||
|
onNextPage={() =>
|
||||||
|
setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1))
|
||||||
|
}
|
||||||
|
onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))}
|
||||||
|
onRowClick={(value) =>
|
||||||
|
pushModal('PageDetails', { type: 'page', projectId, value })
|
||||||
|
}
|
||||||
|
onSearchChange={(v) => {
|
||||||
|
setPagesSearch(v);
|
||||||
|
setPagesPage(0);
|
||||||
|
}}
|
||||||
|
pageCount={pagesPageCount}
|
||||||
|
pageIndex={pagesPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
rows={paginatedPages}
|
||||||
|
searchPlaceholder="Search pages"
|
||||||
|
searchValue={pagesSearch}
|
||||||
|
title="Top pages"
|
||||||
|
totalCount={filteredPages.length}
|
||||||
|
/>
|
||||||
|
<GscTable
|
||||||
|
isLoading={queriesQuery.isLoading}
|
||||||
|
keyField="query"
|
||||||
|
keyLabel="Query"
|
||||||
|
maxClicks={Math.max(...paginatedQueries.map((q) => q.clicks), 1)}
|
||||||
|
onNextPage={() =>
|
||||||
|
setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1))
|
||||||
|
}
|
||||||
|
onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))}
|
||||||
|
onRowClick={(value) =>
|
||||||
|
pushModal('PageDetails', { type: 'query', projectId, value })
|
||||||
|
}
|
||||||
|
onSearchChange={(v) => {
|
||||||
|
setQueriesSearch(v);
|
||||||
|
setQueriesPage(0);
|
||||||
|
}}
|
||||||
|
pageCount={queriesPageCount}
|
||||||
|
pageIndex={queriesPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
rows={paginatedQueries}
|
||||||
|
searchPlaceholder="Search queries"
|
||||||
|
searchValue={queriesSearch}
|
||||||
|
title="Top queries"
|
||||||
|
totalCount={filteredQueries.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<GscCannibalization
|
||||||
|
endDate={endDate ?? undefined}
|
||||||
|
interval={interval ?? 'day'}
|
||||||
|
projectId={projectId}
|
||||||
|
range={range}
|
||||||
|
startDate={startDate ?? undefined}
|
||||||
|
/>
|
||||||
|
<PagesInsights projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficSourceWidget({
|
||||||
|
title,
|
||||||
|
engines,
|
||||||
|
total,
|
||||||
|
previousTotal,
|
||||||
|
isLoading,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
engines: Array<{ name: string; sessions: number }>;
|
||||||
|
total: number;
|
||||||
|
previousTotal: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
const displayed =
|
||||||
|
engines.length > 8
|
||||||
|
? [
|
||||||
|
...engines.slice(0, 7),
|
||||||
|
{
|
||||||
|
name: 'Others',
|
||||||
|
sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: engines.slice(0, 8);
|
||||||
|
|
||||||
|
const max = displayed[0]?.sessions ?? 1;
|
||||||
|
const pctChange =
|
||||||
|
previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<h3 className="font-medium text-sm">{title}</h3>
|
||||||
|
{!isLoading && total > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium font-mono text-sm tabular-nums">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{pctChange !== null && (
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||||
|
>
|
||||||
|
{pctChange >= 0 ? '+' : ''}
|
||||||
|
{pctChange.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2">
|
||||||
|
{isLoading &&
|
||||||
|
[1, 2, 3, 4].map((i) => (
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
|
||||||
|
<div className="size-4 animate-pulse rounded-sm bg-muted" />
|
||||||
|
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && engines.length === 0 && (
|
||||||
|
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
|
||||||
|
{emptyMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isLoading &&
|
||||||
|
displayed.map((engine) => {
|
||||||
|
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
|
||||||
|
const barPct = (engine.sessions / max) * 100;
|
||||||
|
return (
|
||||||
|
<div className="relative px-4 py-2.5" key={engine.name}>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-muted/50"
|
||||||
|
style={{ width: `${barPct}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
{engine.name !== 'Others' && (
|
||||||
|
<SerieIcon
|
||||||
|
className="size-3.5 shrink-0 rounded-sm"
|
||||||
|
name={engine.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs capitalize">
|
||||||
|
{engine.name.replace(/\..+$/, '')}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs tabular-nums">
|
||||||
|
{engine.sessions.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||||
|
{pct.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchEngines(props: {
|
||||||
|
engines: Array<{ name: string; sessions: number }>;
|
||||||
|
total: number;
|
||||||
|
previousTotal: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TrafficSourceWidget
|
||||||
|
{...props}
|
||||||
|
emptyMessage="No search traffic in this period"
|
||||||
|
title="Search engines"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AiEngines(props: {
|
||||||
|
engines: Array<{ name: string; sessions: number }>;
|
||||||
|
total: number;
|
||||||
|
previousTotal: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TrafficSourceWidget
|
||||||
|
{...props}
|
||||||
|
emptyMessage="No AI traffic in this period"
|
||||||
|
title="AI referrals"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GscChart({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
data: Array<{ date: string; clicks: number; impressions: number }>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const color = getChartColor(0);
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : (
|
||||||
|
<TooltipProvider>
|
||||||
|
<ResponsiveContainer height={200} width="100%">
|
||||||
|
<ComposedChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
height="140%"
|
||||||
|
id="gsc-line-glow"
|
||||||
|
width="140%"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA slope="0.5" type="linear" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
className="stroke-border"
|
||||||
|
horizontal
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...X_AXIS_STYLE_PROPS}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
type="category"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} yAxisId="left" />
|
||||||
|
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||||
|
<GscTooltip />
|
||||||
|
<Line
|
||||||
|
dataKey="clicks"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-line-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="impressions"
|
||||||
|
dot={false}
|
||||||
|
filter="url(#gsc-line-glow)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke={getChartColor(1)}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GscTableRow {
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GscTable({
|
||||||
|
title,
|
||||||
|
rows,
|
||||||
|
keyField,
|
||||||
|
keyLabel,
|
||||||
|
maxClicks,
|
||||||
|
isLoading,
|
||||||
|
onRowClick,
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder,
|
||||||
|
totalCount,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
onPreviousPage,
|
||||||
|
onNextPage,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
rows: GscTableRow[];
|
||||||
|
keyField: string;
|
||||||
|
keyLabel: string;
|
||||||
|
maxClicks: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRowClick?: (value: string) => void;
|
||||||
|
searchValue?: string;
|
||||||
|
onSearchChange?: (value: string) => void;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
totalCount?: number;
|
||||||
|
pageIndex?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
pageCount?: number;
|
||||||
|
onPreviousPage?: () => void;
|
||||||
|
onNextPage?: () => void;
|
||||||
|
}) {
|
||||||
|
const showPagination =
|
||||||
|
totalCount != null &&
|
||||||
|
pageSize != null &&
|
||||||
|
pageCount != null &&
|
||||||
|
onPreviousPage != null &&
|
||||||
|
onNextPage != null &&
|
||||||
|
pageIndex != null;
|
||||||
|
const canPreviousPage = (pageIndex ?? 0) > 0;
|
||||||
|
const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1;
|
||||||
|
const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0;
|
||||||
|
const rangeEnd = Math.min(
|
||||||
|
(pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0),
|
||||||
|
totalCount ?? 0
|
||||||
|
);
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<h3 className="font-medium text-sm">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<OverviewWidgetTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: keyLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
render: () => <Skeleton className="h-4 w-10" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impr.',
|
||||||
|
width: '70px',
|
||||||
|
render: () => <Skeleton className="h-4 w-10" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CTR',
|
||||||
|
width: '60px',
|
||||||
|
render: () => <Skeleton className="h-4 w-8" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
render: () => <Skeleton className="h-4 w-8" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={[1, 2, 3, 4, 5]}
|
||||||
|
getColumnPercentage={() => 0}
|
||||||
|
keyExtractor={(i) => String(i)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="border-b">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<h3 className="font-medium text-sm">{title}</h3>
|
||||||
|
{showPagination && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||||
|
{totalCount === 0
|
||||||
|
? '0 results'
|
||||||
|
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
|
||||||
|
</span>
|
||||||
|
<Pagination
|
||||||
|
canNextPage={canNextPage}
|
||||||
|
canPreviousPage={canPreviousPage}
|
||||||
|
nextPage={onNextPage}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
previousPage={onPreviousPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onSearchChange != null && (
|
||||||
|
<div className="relative border-t">
|
||||||
|
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="rounded-none border-0 border-y bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder ?? 'Search'}
|
||||||
|
type="search"
|
||||||
|
value={searchValue ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<OverviewWidgetTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: keyLabel,
|
||||||
|
width: 'w-full',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<button
|
||||||
|
className="block w-full truncate text-left font-mono text-xs hover:underline"
|
||||||
|
onClick={() => onRowClick?.(String(item[keyField]))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{String(item[keyField])}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clicks',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.clicks,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{item.clicks.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impr.',
|
||||||
|
width: '70px',
|
||||||
|
getSortValue: (item) => item.impressions,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{item.impressions.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CTR',
|
||||||
|
width: '60px',
|
||||||
|
getSortValue: (item) => item.ctr,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{(item.ctr * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pos.',
|
||||||
|
width: '55px',
|
||||||
|
getSortValue: (item) => item.position,
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono font-semibold text-xs">
|
||||||
|
{item.position.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={rows}
|
||||||
|
getColumnPercentage={(item) => item.clicks / maxClicks}
|
||||||
|
keyExtractor={(item) => String(item[keyField])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||||
|
)({
|
||||||
|
component: GscSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
function GscSettings() {
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedSite, setSelectedSite] = useState('');
|
||||||
|
|
||||||
|
const connectionQuery = useQuery(
|
||||||
|
trpc.gsc.getConnection.queryOptions(
|
||||||
|
{ projectId },
|
||||||
|
{ refetchInterval: 5000 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sitesQuery = useQuery(
|
||||||
|
trpc.gsc.getSites.queryOptions(
|
||||||
|
{ projectId },
|
||||||
|
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const initiateOAuth = useMutation(
|
||||||
|
trpc.gsc.initiateOAuth.mutationOptions({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
window.location.href = data.url;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to initiate Google Search Console connection');
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectSite = useMutation(
|
||||||
|
trpc.gsc.selectSite.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Site connected', {
|
||||||
|
description: 'Backfill of 6 months of data has started.',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to select site');
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const disconnect = useMutation(
|
||||||
|
trpc.gsc.disconnect.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Disconnected from Google Search Console');
|
||||||
|
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to disconnect');
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = connectionQuery.data;
|
||||||
|
|
||||||
|
if (connectionQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not connected at all
|
||||||
|
if (!connection) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
Connect your Google Search Console property to import search
|
||||||
|
performance data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border p-6">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
You will be redirected to Google to authorize access. Only read-only
|
||||||
|
access to Search Console data is requested.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
disabled={initiateOAuth.isPending}
|
||||||
|
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||||
|
>
|
||||||
|
{initiateOAuth.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Connect Google Search Console
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected but no site selected yet
|
||||||
|
if (!connection.siteUrl) {
|
||||||
|
const sites = sitesQuery.data ?? [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-lg">Select a property</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
Choose which Google Search Console property to connect to this
|
||||||
|
project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 rounded-lg border p-6">
|
||||||
|
{sitesQuery.isLoading ? (
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
) : sites.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No Search Console properties found for this Google account.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select onValueChange={setSelectedSite} value={selectedSite}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a property..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<SelectItem key={site.siteUrl} value={site.siteUrl}>
|
||||||
|
{site.siteUrl}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedSite || selectSite.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
selectSite.mutate({ projectId, siteUrl: selectedSite })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectSite.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Connect property
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => disconnect.mutate({ projectId })}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired — show reconnect prompt
|
||||||
|
if (connection.lastSyncStatus === 'token_expired') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
Connected to Google Search Console.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span className="font-medium text-sm">Authorization expired</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Your Google Search Console authorization has expired or been
|
||||||
|
revoked. Please reconnect to continue syncing data.
|
||||||
|
</p>
|
||||||
|
{connection.lastSyncError && (
|
||||||
|
<p className="break-words font-mono text-muted-foreground text-xs">
|
||||||
|
{connection.lastSyncError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
disabled={initiateOAuth.isPending}
|
||||||
|
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||||
|
>
|
||||||
|
{initiateOAuth.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Reconnect Google Search Console
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={disconnect.isPending}
|
||||||
|
onClick={() => disconnect.mutate({ projectId })}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{disconnect.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fully connected
|
||||||
|
const syncStatusIcon =
|
||||||
|
connection.lastSyncStatus === 'success' ? (
|
||||||
|
<CheckCircleIcon className="h-4 w-4" />
|
||||||
|
) : connection.lastSyncStatus === 'error' ? (
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const syncStatusVariant =
|
||||||
|
connection.lastSyncStatus === 'success'
|
||||||
|
? 'success'
|
||||||
|
: connection.lastSyncStatus === 'error'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
Connected to Google Search Console.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="font-medium text-sm">Property</div>
|
||||||
|
<div className="font-mono text-muted-foreground text-sm">
|
||||||
|
{connection.siteUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connection.backfillStatus && (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="font-medium text-sm">Backfill</div>
|
||||||
|
<Badge
|
||||||
|
className="capitalize"
|
||||||
|
variant={
|
||||||
|
connection.backfillStatus === 'completed'
|
||||||
|
? 'success'
|
||||||
|
: connection.backfillStatus === 'failed'
|
||||||
|
? 'destructive'
|
||||||
|
: connection.backfillStatus === 'running'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{connection.backfillStatus === 'running' && (
|
||||||
|
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
{connection.backfillStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connection.lastSyncedAt && (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="font-medium text-sm">Last synced</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{connection.lastSyncStatus && (
|
||||||
|
<Badge
|
||||||
|
className="capitalize"
|
||||||
|
variant={syncStatusVariant as any}
|
||||||
|
>
|
||||||
|
{syncStatusIcon}
|
||||||
|
{connection.lastSyncStatus}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connection.lastSyncError && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="font-medium text-destructive text-sm">
|
||||||
|
Last error
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
|
||||||
|
{connection.lastSyncError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disconnect.isPending}
|
||||||
|
onClick={() => disconnect.mutate({ projectId })}
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{disconnect.isPending && (
|
||||||
|
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ function ProjectDashboard() {
|
|||||||
{ id: 'tracking', label: 'Tracking script' },
|
{ id: 'tracking', label: 'Tracking script' },
|
||||||
{ id: 'widgets', label: 'Widgets' },
|
{ id: 'widgets', label: 'Widgets' },
|
||||||
{ id: 'imports', label: 'Imports' },
|
{ id: 'imports', label: 'Imports' },
|
||||||
|
{ id: 'gsc', label: 'Google Search' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export async function bootCron() {
|
|||||||
type: 'onboarding',
|
type: 'onboarding',
|
||||||
pattern: '0 * * * *',
|
pattern: '0 * * * *',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'gscSync',
|
||||||
|
type: 'gscSync',
|
||||||
|
pattern: '0 3 * * *',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type EventsQueuePayloadIncomingEvent,
|
type EventsQueuePayloadIncomingEvent,
|
||||||
cronQueue,
|
cronQueue,
|
||||||
eventsGroupQueues,
|
eventsGroupQueues,
|
||||||
|
gscQueue,
|
||||||
importQueue,
|
importQueue,
|
||||||
insightsQueue,
|
insightsQueue,
|
||||||
miscQueue,
|
miscQueue,
|
||||||
@@ -20,6 +21,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
import { Worker as GroupWorker } from 'groupmq';
|
import { Worker as GroupWorker } from 'groupmq';
|
||||||
|
|
||||||
import { cronJob } from './jobs/cron';
|
import { cronJob } from './jobs/cron';
|
||||||
|
import { gscJob } from './jobs/gsc';
|
||||||
import { incomingEvent } from './jobs/events.incoming-event';
|
import { incomingEvent } from './jobs/events.incoming-event';
|
||||||
import { importJob } from './jobs/import';
|
import { importJob } from './jobs/import';
|
||||||
import { insightsProjectJob } from './jobs/insights';
|
import { insightsProjectJob } from './jobs/insights';
|
||||||
@@ -59,6 +61,7 @@ function getEnabledQueues(): QueueName[] {
|
|||||||
'misc',
|
'misc',
|
||||||
'import',
|
'import',
|
||||||
'insights',
|
'insights',
|
||||||
|
'gsc',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +211,17 @@ export async function bootWorkers() {
|
|||||||
logger.info('Started worker for insights', { concurrency });
|
logger.info('Started worker for insights', { concurrency });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start gsc worker
|
||||||
|
if (enabledQueues.includes('gsc')) {
|
||||||
|
const concurrency = getConcurrencyFor('gsc', 5);
|
||||||
|
const gscWorker = new Worker(gscQueue.name, gscJob, {
|
||||||
|
...workerOptions,
|
||||||
|
concurrency,
|
||||||
|
});
|
||||||
|
workers.push(gscWorker);
|
||||||
|
logger.info('Started worker for gsc', { concurrency });
|
||||||
|
}
|
||||||
|
|
||||||
if (workers.length === 0) {
|
if (workers.length === 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'No workers started. Check ENABLED_QUEUES environment variable.',
|
'No workers started. Check ENABLED_QUEUES environment variable.',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
|
|||||||
import type { CronQueuePayload } from '@openpanel/queue';
|
import type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
import { jobdeleteProjects } from './cron.delete-projects';
|
import { jobdeleteProjects } from './cron.delete-projects';
|
||||||
|
import { gscSyncAllJob } from './gsc';
|
||||||
import { onboardingJob } from './cron.onboarding';
|
import { onboardingJob } from './cron.onboarding';
|
||||||
import { ping } from './cron.ping';
|
import { ping } from './cron.ping';
|
||||||
import { salt } from './cron.salt';
|
import { salt } from './cron.salt';
|
||||||
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
|||||||
case 'onboarding': {
|
case 'onboarding': {
|
||||||
return await onboardingJob(job);
|
return await onboardingJob(job);
|
||||||
}
|
}
|
||||||
|
case 'gscSync': {
|
||||||
|
return await gscSyncAllJob();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/worker/src/jobs/gsc.ts
Normal file
142
apps/worker/src/jobs/gsc.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { db, syncGscData } from '@openpanel/db';
|
||||||
|
import { gscQueue } from '@openpanel/queue';
|
||||||
|
import type { GscQueuePayload } from '@openpanel/queue';
|
||||||
|
import type { Job } from 'bullmq';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
const BACKFILL_MONTHS = 6;
|
||||||
|
const CHUNK_DAYS = 14;
|
||||||
|
|
||||||
|
export async function gscJob(job: Job<GscQueuePayload>) {
|
||||||
|
switch (job.data.type) {
|
||||||
|
case 'gscProjectSync':
|
||||||
|
return gscProjectSyncJob(job.data.payload.projectId);
|
||||||
|
case 'gscProjectBackfill':
|
||||||
|
return gscProjectBackfillJob(job.data.payload.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gscProjectSyncJob(projectId: string) {
|
||||||
|
const conn = await db.gscConnection.findUnique({ where: { projectId } });
|
||||||
|
if (!conn?.siteUrl) {
|
||||||
|
logger.warn('GSC sync skipped: no connection or siteUrl', { projectId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sync rolling 3-day window (GSC data can arrive late)
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setDate(endDate.getDate() - 1); // yesterday
|
||||||
|
const startDate = new Date(endDate);
|
||||||
|
startDate.setDate(startDate.getDate() - 2); // 3 days total
|
||||||
|
|
||||||
|
await syncGscData(projectId, startDate, endDate);
|
||||||
|
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: {
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
lastSyncStatus: 'success',
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info('GSC sync completed', { projectId });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: {
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
lastSyncStatus: 'error',
|
||||||
|
lastSyncError: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.error('GSC sync failed', { projectId, error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gscProjectBackfillJob(projectId: string) {
|
||||||
|
const conn = await db.gscConnection.findUnique({ where: { projectId } });
|
||||||
|
if (!conn?.siteUrl) {
|
||||||
|
logger.warn('GSC backfill skipped: no connection or siteUrl', { projectId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: { backfillStatus: 'running' },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setDate(endDate.getDate() - 1); // yesterday
|
||||||
|
|
||||||
|
const startDate = new Date(endDate);
|
||||||
|
startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS);
|
||||||
|
|
||||||
|
// Process in chunks to avoid timeouts and respect API limits
|
||||||
|
let chunkEnd = new Date(endDate);
|
||||||
|
while (chunkEnd > startDate) {
|
||||||
|
const chunkStart = new Date(chunkEnd);
|
||||||
|
chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1);
|
||||||
|
if (chunkStart < startDate) {
|
||||||
|
chunkStart.setTime(startDate.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('GSC backfill chunk', {
|
||||||
|
projectId,
|
||||||
|
from: chunkStart.toISOString().slice(0, 10),
|
||||||
|
to: chunkEnd.toISOString().slice(0, 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncGscData(projectId, chunkStart, chunkEnd);
|
||||||
|
|
||||||
|
chunkEnd = new Date(chunkStart);
|
||||||
|
chunkEnd.setDate(chunkEnd.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: {
|
||||||
|
backfillStatus: 'completed',
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
lastSyncStatus: 'success',
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info('GSC backfill completed', { projectId });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: {
|
||||||
|
backfillStatus: 'failed',
|
||||||
|
lastSyncStatus: 'error',
|
||||||
|
lastSyncError: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.error('GSC backfill failed', { projectId, error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gscSyncAllJob() {
|
||||||
|
const connections = await db.gscConnection.findMany({
|
||||||
|
where: {
|
||||||
|
siteUrl: { not: '' },
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('GSC nightly sync: enqueuing projects', {
|
||||||
|
count: connections.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
await gscQueue.add('gscProjectSync', {
|
||||||
|
type: 'gscProjectSync',
|
||||||
|
payload: { projectId: conn.projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 +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(
|
||||||
|
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
|
process.env.GSC_GOOGLE_REDIRECT_URI ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration';
|
||||||
|
import { getIsCluster } from './helpers';
|
||||||
|
|
||||||
|
export async function up() {
|
||||||
|
const isClustered = getIsCluster();
|
||||||
|
|
||||||
|
const commonMetricColumns = [
|
||||||
|
'`clicks` UInt32 CODEC(Delta(4), LZ4)',
|
||||||
|
'`impressions` UInt32 CODEC(Delta(4), LZ4)',
|
||||||
|
'`ctr` Float32 CODEC(Gorilla, LZ4)',
|
||||||
|
'`position` Float32 CODEC(Gorilla, LZ4)',
|
||||||
|
'`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const sqls: string[] = [
|
||||||
|
// Daily totals — accurate overview numbers
|
||||||
|
...createTable({
|
||||||
|
name: 'gsc_daily',
|
||||||
|
columns: [
|
||||||
|
'`project_id` String CODEC(ZSTD(3))',
|
||||||
|
'`date` Date CODEC(Delta(2), LZ4)',
|
||||||
|
...commonMetricColumns,
|
||||||
|
],
|
||||||
|
orderBy: ['project_id', 'date'],
|
||||||
|
partitionBy: 'toYYYYMM(date)',
|
||||||
|
engine: 'ReplacingMergeTree(synced_at)',
|
||||||
|
distributionHash: 'cityHash64(project_id)',
|
||||||
|
replicatedVersion: '1',
|
||||||
|
isClustered,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Per-page breakdown
|
||||||
|
...createTable({
|
||||||
|
name: 'gsc_pages_daily',
|
||||||
|
columns: [
|
||||||
|
'`project_id` String CODEC(ZSTD(3))',
|
||||||
|
'`date` Date CODEC(Delta(2), LZ4)',
|
||||||
|
'`page` String CODEC(ZSTD(3))',
|
||||||
|
...commonMetricColumns,
|
||||||
|
],
|
||||||
|
orderBy: ['project_id', 'date', 'page'],
|
||||||
|
partitionBy: 'toYYYYMM(date)',
|
||||||
|
engine: 'ReplacingMergeTree(synced_at)',
|
||||||
|
distributionHash: 'cityHash64(project_id)',
|
||||||
|
replicatedVersion: '1',
|
||||||
|
isClustered,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Per-query breakdown
|
||||||
|
...createTable({
|
||||||
|
name: 'gsc_queries_daily',
|
||||||
|
columns: [
|
||||||
|
'`project_id` String CODEC(ZSTD(3))',
|
||||||
|
'`date` Date CODEC(Delta(2), LZ4)',
|
||||||
|
'`query` String CODEC(ZSTD(3))',
|
||||||
|
...commonMetricColumns,
|
||||||
|
],
|
||||||
|
orderBy: ['project_id', 'date', 'query'],
|
||||||
|
partitionBy: 'toYYYYMM(date)',
|
||||||
|
engine: 'ReplacingMergeTree(synced_at)',
|
||||||
|
distributionHash: 'cityHash64(project_id)',
|
||||||
|
replicatedVersion: '1',
|
||||||
|
isClustered,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(__filename.replace('.ts', '.sql')),
|
||||||
|
sqls
|
||||||
|
.map((sql) =>
|
||||||
|
sql
|
||||||
|
.trim()
|
||||||
|
.replace(/;$/, '')
|
||||||
|
.replace(/\n{2,}/g, '\n')
|
||||||
|
.concat(';'),
|
||||||
|
)
|
||||||
|
.join('\n\n---\n\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!process.argv.includes('--dry')) {
|
||||||
|
await runClickhouseMigrationCommands(sqls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,3 +31,5 @@ export * from './src/services/overview.service';
|
|||||||
export * from './src/services/pages.service';
|
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/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;
|
||||||
@@ -203,6 +203,7 @@ model Project {
|
|||||||
notificationRules NotificationRule[]
|
notificationRules NotificationRule[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
imports Import[]
|
imports Import[]
|
||||||
|
gscConnection GscConnection?
|
||||||
|
|
||||||
// When deleteAt > now(), the project will be deleted
|
// When deleteAt > now(), the project will be deleted
|
||||||
deleteAt DateTime?
|
deleteAt DateTime?
|
||||||
@@ -612,6 +613,24 @@ model InsightEvent {
|
|||||||
@@map("insight_events")
|
@@map("insight_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GscConnection {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
projectId String @unique
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
siteUrl String @default("")
|
||||||
|
accessToken String
|
||||||
|
refreshToken String
|
||||||
|
accessTokenExpiresAt DateTime?
|
||||||
|
lastSyncedAt DateTime?
|
||||||
|
lastSyncStatus String?
|
||||||
|
lastSyncError String?
|
||||||
|
backfillStatus String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("gsc_connections")
|
||||||
|
}
|
||||||
|
|
||||||
model EmailUnsubscribe {
|
model EmailUnsubscribe {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
email String
|
email String
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export const TABLE_NAMES = {
|
|||||||
sessions: 'sessions',
|
sessions: 'sessions',
|
||||||
events_imports: 'events_imports',
|
events_imports: 'events_imports',
|
||||||
session_replay_chunks: 'session_replay_chunks',
|
session_replay_chunks: 'session_replay_chunks',
|
||||||
|
gsc_daily: 'gsc_daily',
|
||||||
|
gsc_pages_daily: 'gsc_pages_daily',
|
||||||
|
gsc_queries_daily: 'gsc_queries_daily',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
554
packages/db/src/gsc.ts
Normal file
554
packages/db/src/gsc.ts
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import { cacheable } from '@openpanel/redis';
|
||||||
|
import { originalCh } from './clickhouse/client';
|
||||||
|
import { decrypt, encrypt } from './encryption';
|
||||||
|
import { db } from './prisma-client';
|
||||||
|
|
||||||
|
export interface GscSite {
|
||||||
|
siteUrl: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGscToken(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<{ accessToken: string; expiresAt: Date }> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
|
||||||
|
client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Failed to refresh GSC token: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
const expiresAt = new Date(Date.now() + data.expires_in * 1000);
|
||||||
|
return { accessToken: data.access_token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGscAccessToken(projectId: string): Promise<string> {
|
||||||
|
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||||
|
where: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
conn.accessTokenExpiresAt &&
|
||||||
|
conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000
|
||||||
|
) {
|
||||||
|
return decrypt(conn.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accessToken, expiresAt } = await refreshGscToken(
|
||||||
|
decrypt(conn.refreshToken)
|
||||||
|
);
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: { accessToken: encrypt(accessToken), accessTokenExpiresAt: expiresAt },
|
||||||
|
});
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId },
|
||||||
|
data: {
|
||||||
|
lastSyncStatus: 'token_expired',
|
||||||
|
lastSyncError:
|
||||||
|
error instanceof Error ? error.message : 'Failed to refresh token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
'GSC token has expired or been revoked. Please reconnect Google Search Console.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGscSites(projectId: string): Promise<GscSite[]> {
|
||||||
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
|
const res = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Failed to list GSC sites: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>;
|
||||||
|
};
|
||||||
|
return data.siteEntry ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GscApiRow {
|
||||||
|
keys: string[];
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GscDimensionFilter {
|
||||||
|
dimension: string;
|
||||||
|
operator: string;
|
||||||
|
expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GscFilterGroup {
|
||||||
|
filters: GscDimensionFilter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryGscSearchAnalytics(
|
||||||
|
accessToken: string,
|
||||||
|
siteUrl: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
dimensions: string[],
|
||||||
|
dimensionFilterGroups?: GscFilterGroup[]
|
||||||
|
): Promise<GscApiRow[]> {
|
||||||
|
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
||||||
|
const url = `https://www.googleapis.com/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`;
|
||||||
|
|
||||||
|
const allRows: GscApiRow[] = [];
|
||||||
|
let startRow = 0;
|
||||||
|
const rowLimit = 25000;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
dimensions,
|
||||||
|
rowLimit,
|
||||||
|
startRow,
|
||||||
|
dataState: 'all',
|
||||||
|
...(dimensionFilterGroups && { dimensionFilterGroups }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { rows?: GscApiRow[] };
|
||||||
|
const rows = data.rows ?? [];
|
||||||
|
allRows.push(...rows);
|
||||||
|
|
||||||
|
if (rows.length < rowLimit) break;
|
||||||
|
startRow += rowLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowString(): string {
|
||||||
|
return new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGscData(
|
||||||
|
projectId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<void> {
|
||||||
|
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||||
|
where: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conn.siteUrl) {
|
||||||
|
throw new Error('No GSC site URL configured for this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
|
const start = formatDate(startDate);
|
||||||
|
const end = formatDate(endDate);
|
||||||
|
const syncedAt = nowString();
|
||||||
|
|
||||||
|
// 1. Daily totals — authoritative numbers for overview chart
|
||||||
|
const dailyRows = await queryGscSearchAnalytics(
|
||||||
|
accessToken,
|
||||||
|
conn.siteUrl,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
['date']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dailyRows.length > 0) {
|
||||||
|
await originalCh.insert({
|
||||||
|
table: 'gsc_daily',
|
||||||
|
values: dailyRows.map((row) => ({
|
||||||
|
project_id: projectId,
|
||||||
|
date: row.keys[0] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
synced_at: syncedAt,
|
||||||
|
})),
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Per-page breakdown
|
||||||
|
const pageRows = await queryGscSearchAnalytics(
|
||||||
|
accessToken,
|
||||||
|
conn.siteUrl,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
['date', 'page']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pageRows.length > 0) {
|
||||||
|
await originalCh.insert({
|
||||||
|
table: 'gsc_pages_daily',
|
||||||
|
values: pageRows.map((row) => ({
|
||||||
|
project_id: projectId,
|
||||||
|
date: row.keys[0] ?? '',
|
||||||
|
page: row.keys[1] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
synced_at: syncedAt,
|
||||||
|
})),
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Per-query breakdown
|
||||||
|
const queryRows = await queryGscSearchAnalytics(
|
||||||
|
accessToken,
|
||||||
|
conn.siteUrl,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
['date', 'query']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (queryRows.length > 0) {
|
||||||
|
await originalCh.insert({
|
||||||
|
table: 'gsc_queries_daily',
|
||||||
|
values: queryRows.map((row) => ({
|
||||||
|
project_id: projectId,
|
||||||
|
date: row.keys[0] ?? '',
|
||||||
|
query: row.keys[1] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
synced_at: syncedAt,
|
||||||
|
})),
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGscOverview(
|
||||||
|
projectId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
interval: 'day' | 'week' | 'month' = 'day'
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
date: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const dateExpr =
|
||||||
|
interval === 'month'
|
||||||
|
? 'toStartOfMonth(date)'
|
||||||
|
: interval === 'week'
|
||||||
|
? 'toStartOfWeek(date)'
|
||||||
|
: 'date';
|
||||||
|
|
||||||
|
const result = await originalCh.query({
|
||||||
|
query: `
|
||||||
|
SELECT
|
||||||
|
${dateExpr} as date,
|
||||||
|
sum(clicks) as clicks,
|
||||||
|
sum(impressions) as impressions,
|
||||||
|
avg(ctr) as ctr,
|
||||||
|
avg(position) as position
|
||||||
|
FROM gsc_daily
|
||||||
|
FINAL
|
||||||
|
WHERE project_id = {projectId: String}
|
||||||
|
AND date >= {startDate: String}
|
||||||
|
AND date <= {endDate: String}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`,
|
||||||
|
query_params: { projectId, startDate, endDate },
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
return result.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGscPages(
|
||||||
|
projectId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
limit = 100
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
page: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await originalCh.query({
|
||||||
|
query: `
|
||||||
|
SELECT
|
||||||
|
page,
|
||||||
|
sum(clicks) as clicks,
|
||||||
|
sum(impressions) as impressions,
|
||||||
|
avg(ctr) as ctr,
|
||||||
|
avg(position) as position
|
||||||
|
FROM gsc_pages_daily
|
||||||
|
FINAL
|
||||||
|
WHERE project_id = {projectId: String}
|
||||||
|
AND date >= {startDate: String}
|
||||||
|
AND date <= {endDate: String}
|
||||||
|
GROUP BY page
|
||||||
|
ORDER BY clicks DESC
|
||||||
|
LIMIT {limit: UInt32}
|
||||||
|
`,
|
||||||
|
query_params: { projectId, startDate, endDate, limit },
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
return result.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GscCannibalizedQuery {
|
||||||
|
query: string;
|
||||||
|
totalImpressions: number;
|
||||||
|
totalClicks: number;
|
||||||
|
pages: Array<{
|
||||||
|
page: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGscCannibalization = cacheable(
|
||||||
|
async (
|
||||||
|
projectId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
): Promise<GscCannibalizedQuery[]> => {
|
||||||
|
const conn = await db.gscConnection.findUniqueOrThrow({
|
||||||
|
where: { projectId },
|
||||||
|
});
|
||||||
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
|
|
||||||
|
const rows = await queryGscSearchAnalytics(
|
||||||
|
accessToken,
|
||||||
|
conn.siteUrl,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
['query', 'page']
|
||||||
|
);
|
||||||
|
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
totalImpressions: number;
|
||||||
|
totalClicks: number;
|
||||||
|
pages: GscCannibalizedQuery['pages'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const query = row.keys[0] ?? '';
|
||||||
|
// Strip hash fragments — GSC records heading anchors (e.g. /page#section)
|
||||||
|
// as separate URLs but Google treats them as the same page
|
||||||
|
let page = row.keys[1] ?? '';
|
||||||
|
try {
|
||||||
|
const u = new URL(page);
|
||||||
|
u.hash = '';
|
||||||
|
page = u.toString();
|
||||||
|
} catch {
|
||||||
|
page = page.split('#')[0] ?? page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = map.get(query) ?? {
|
||||||
|
totalImpressions: 0,
|
||||||
|
totalClicks: 0,
|
||||||
|
pages: [],
|
||||||
|
};
|
||||||
|
entry.totalImpressions += row.impressions;
|
||||||
|
entry.totalClicks += row.clicks;
|
||||||
|
// Merge into existing page entry if already seen (from a different hash variant)
|
||||||
|
const existing = entry.pages.find((p) => p.page === page);
|
||||||
|
if (existing) {
|
||||||
|
const totalImpressions = existing.impressions + row.impressions;
|
||||||
|
if (totalImpressions > 0) {
|
||||||
|
existing.position =
|
||||||
|
(existing.position * existing.impressions + row.position * row.impressions) / totalImpressions;
|
||||||
|
}
|
||||||
|
existing.clicks += row.clicks;
|
||||||
|
existing.impressions += row.impressions;
|
||||||
|
existing.ctr =
|
||||||
|
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
|
||||||
|
} else {
|
||||||
|
entry.pages.push({
|
||||||
|
page,
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.set(query, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.entries()]
|
||||||
|
.filter(([, v]) => v.pages.length >= 2 && v.totalImpressions >= 100)
|
||||||
|
.sort(([, a], [, b]) => b.totalImpressions - a.totalImpressions)
|
||||||
|
.slice(0, 50)
|
||||||
|
.map(([query, v]) => ({
|
||||||
|
query,
|
||||||
|
totalImpressions: v.totalImpressions,
|
||||||
|
totalClicks: v.totalClicks,
|
||||||
|
pages: v.pages.sort((a, b) =>
|
||||||
|
a.position !== b.position
|
||||||
|
? a.position - b.position
|
||||||
|
: b.impressions - a.impressions
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
60 * 60 * 4
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function getGscPageDetails(
|
||||||
|
projectId: string,
|
||||||
|
page: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
): Promise<{
|
||||||
|
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||||
|
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||||
|
}> {
|
||||||
|
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
|
||||||
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
|
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'page', operator: 'equals', expression: page }] }];
|
||||||
|
|
||||||
|
const [timeseriesRows, queryRows] = await Promise.all([
|
||||||
|
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
|
||||||
|
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['query'], filterGroups),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeseries: timeseriesRows.map((row) => ({
|
||||||
|
date: row.keys[0] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
})),
|
||||||
|
queries: queryRows.map((row) => ({
|
||||||
|
query: row.keys[0] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGscQueryDetails(
|
||||||
|
projectId: string,
|
||||||
|
query: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
): Promise<{
|
||||||
|
timeseries: Array<{ date: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||||
|
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; position: number }>;
|
||||||
|
}> {
|
||||||
|
const conn = await db.gscConnection.findUniqueOrThrow({ where: { projectId } });
|
||||||
|
const accessToken = await getGscAccessToken(projectId);
|
||||||
|
const filterGroups: GscFilterGroup[] = [{ filters: [{ dimension: 'query', operator: 'equals', expression: query }] }];
|
||||||
|
|
||||||
|
const [timeseriesRows, pageRows] = await Promise.all([
|
||||||
|
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['date'], filterGroups),
|
||||||
|
queryGscSearchAnalytics(accessToken, conn.siteUrl, startDate, endDate, ['page'], filterGroups),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeseries: timeseriesRows.map((row) => ({
|
||||||
|
date: row.keys[0] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
})),
|
||||||
|
pages: pageRows.map((row) => ({
|
||||||
|
page: row.keys[0] ?? '',
|
||||||
|
clicks: row.clicks,
|
||||||
|
impressions: row.impressions,
|
||||||
|
ctr: row.ctr,
|
||||||
|
position: row.position,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGscQueries(
|
||||||
|
projectId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
limit = 100
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
query: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await originalCh.query({
|
||||||
|
query: `
|
||||||
|
SELECT
|
||||||
|
query,
|
||||||
|
sum(clicks) as clicks,
|
||||||
|
sum(impressions) as impressions,
|
||||||
|
avg(ctr) as ctr,
|
||||||
|
avg(position) as position
|
||||||
|
FROM gsc_queries_daily
|
||||||
|
FINAL
|
||||||
|
WHERE project_id = {projectId: String}
|
||||||
|
AND date >= {startDate: String}
|
||||||
|
AND date <= {endDate: String}
|
||||||
|
GROUP BY query
|
||||||
|
ORDER BY clicks DESC
|
||||||
|
LIMIT {limit: UInt32}
|
||||||
|
`,
|
||||||
|
query_params: { projectId, startDate, endDate, limit },
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
return result.json();
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -7,6 +8,15 @@ export interface IGetPagesInput {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPageTimeseriesRow {
|
||||||
|
origin: string;
|
||||||
|
path: string;
|
||||||
|
date: string;
|
||||||
|
pageviews: number;
|
||||||
|
sessions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITopPage {
|
export interface ITopPage {
|
||||||
@@ -28,6 +38,7 @@ export class PagesService {
|
|||||||
endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
search,
|
search,
|
||||||
|
limit,
|
||||||
}: IGetPagesInput): Promise<ITopPage[]> {
|
}: IGetPagesInput): Promise<ITopPage[]> {
|
||||||
// CTE: Get titles from the last 30 days for faster retrieval
|
// CTE: Get titles from the last 30 days for faster retrieval
|
||||||
const titlesCte = clix(this.client, timezone)
|
const titlesCte = clix(this.client, timezone)
|
||||||
@@ -72,7 +83,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)
|
||||||
@@ -83,14 +94,69 @@ export class PagesService {
|
|||||||
clix.datetime(endDate, 'toDateTime'),
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
])
|
])
|
||||||
.when(!!search, (q) => {
|
.when(!!search, (q) => {
|
||||||
q.where('e.path', 'LIKE', `%${search}%`);
|
const term = `%${search}%`;
|
||||||
|
q.whereGroup()
|
||||||
|
.where('e.path', 'LIKE', term)
|
||||||
|
.orWhere('e.origin', 'LIKE', term)
|
||||||
|
.orWhere('pt.title', 'LIKE', term)
|
||||||
|
.end();
|
||||||
})
|
})
|
||||||
.groupBy(['e.origin', 'e.path', 'pt.title'])
|
.groupBy(['e.origin', 'e.path', 'pt.title'])
|
||||||
.orderBy('sessions', 'DESC')
|
.orderBy('sessions', 'DESC');
|
||||||
.limit(1000);
|
if (limit !== undefined) {
|
||||||
|
query.limit(limit);
|
||||||
|
}
|
||||||
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);
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ export type CronQueuePayloadFlushReplay = {
|
|||||||
type: 'flushReplay';
|
type: 'flushReplay';
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
export type CronQueuePayloadGscSync = {
|
||||||
|
type: 'gscSync';
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
export type CronQueuePayload =
|
export type CronQueuePayload =
|
||||||
| CronQueuePayloadSalt
|
| CronQueuePayloadSalt
|
||||||
| CronQueuePayloadFlushEvents
|
| CronQueuePayloadFlushEvents
|
||||||
@@ -136,7 +140,8 @@ export type CronQueuePayload =
|
|||||||
| CronQueuePayloadPing
|
| CronQueuePayloadPing
|
||||||
| CronQueuePayloadProject
|
| CronQueuePayloadProject
|
||||||
| CronQueuePayloadInsightsDaily
|
| CronQueuePayloadInsightsDaily
|
||||||
| CronQueuePayloadOnboarding;
|
| CronQueuePayloadOnboarding
|
||||||
|
| CronQueuePayloadGscSync;
|
||||||
|
|
||||||
export type MiscQueuePayloadTrialEndingSoon = {
|
export type MiscQueuePayloadTrialEndingSoon = {
|
||||||
type: 'trialEndingSoon';
|
type: 'trialEndingSoon';
|
||||||
@@ -268,3 +273,21 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type GscQueuePayloadSync = {
|
||||||
|
type: 'gscProjectSync';
|
||||||
|
payload: { projectId: string };
|
||||||
|
};
|
||||||
|
export type GscQueuePayloadBackfill = {
|
||||||
|
type: 'gscProjectBackfill';
|
||||||
|
payload: { projectId: string };
|
||||||
|
};
|
||||||
|
export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill;
|
||||||
|
|
||||||
|
export const gscQueue = new Queue<GscQueuePayload>(getQueueName('gsc'), {
|
||||||
|
connection: getRedisQueue(),
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: 50,
|
||||||
|
removeOnFail: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authRouter } from './routers/auth';
|
import { authRouter } from './routers/auth';
|
||||||
|
import { gscRouter } from './routers/gsc';
|
||||||
import { chartRouter } from './routers/chart';
|
import { chartRouter } from './routers/chart';
|
||||||
import { chatRouter } from './routers/chat';
|
import { chatRouter } from './routers/chat';
|
||||||
import { clientRouter } from './routers/client';
|
import { clientRouter } from './routers/client';
|
||||||
@@ -53,6 +54,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
insight: insightRouter,
|
insight: insightRouter,
|
||||||
widget: widgetRouter,
|
widget: widgetRouter,
|
||||||
email: emailRouter,
|
email: emailRouter,
|
||||||
|
gsc: gscRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export const eventRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
cursor: z.number().optional(),
|
cursor: z.number().optional(),
|
||||||
take: z.number().default(20),
|
take: z.number().min(1).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
interval: zTimeInterval,
|
interval: zTimeInterval,
|
||||||
@@ -335,6 +335,80 @@ export const eventRouter = createTRPCRouter({
|
|||||||
endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
search: input.search,
|
search: input.search,
|
||||||
|
limit: input.take,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
pagesTimeseries: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
range: zRange,
|
||||||
|
interval: zTimeInterval,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
|
return pagesService.getPageTimeseries({
|
||||||
|
projectId: input.projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
interval: input.interval,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
previousPages: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
range: zRange,
|
||||||
|
interval: zTimeInterval,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
|
|
||||||
|
const startMs = new Date(startDate).getTime();
|
||||||
|
const endMs = new Date(endDate).getTime();
|
||||||
|
const duration = endMs - startMs;
|
||||||
|
|
||||||
|
const prevEnd = new Date(startMs - 1);
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
const fmt = (d: Date) =>
|
||||||
|
d.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
|
||||||
|
return pagesService.getTopPages({
|
||||||
|
projectId: input.projectId,
|
||||||
|
startDate: fmt(prevStart),
|
||||||
|
endDate: fmt(prevEnd),
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
pageTimeseries: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
range: zRange,
|
||||||
|
interval: zTimeInterval,
|
||||||
|
origin: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
|
return pagesService.getPageTimeseries({
|
||||||
|
projectId: input.projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
interval: input.interval,
|
||||||
|
filterOrigin: input.origin,
|
||||||
|
filterPath: input.path,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
416
packages/trpc/src/routers/gsc.ts
Normal file
416
packages/trpc/src/routers/gsc.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { Arctic, googleGsc } from '@openpanel/auth';
|
||||||
|
import {
|
||||||
|
chQuery,
|
||||||
|
db,
|
||||||
|
getChartStartEndDate,
|
||||||
|
getGscCannibalization,
|
||||||
|
getGscOverview,
|
||||||
|
getGscPageDetails,
|
||||||
|
getGscPages,
|
||||||
|
getGscQueries,
|
||||||
|
getGscQueryDetails,
|
||||||
|
getSettingsForProject,
|
||||||
|
listGscSites,
|
||||||
|
TABLE_NAMES,
|
||||||
|
} from '@openpanel/db';
|
||||||
|
import { gscQueue } from '@openpanel/queue';
|
||||||
|
import { zRange, zTimeInterval } from '@openpanel/validation';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getProjectAccess } from '../access';
|
||||||
|
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||||
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
|
const zGscDateInput = z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
range: zRange,
|
||||||
|
interval: zTimeInterval.optional().default('day'),
|
||||||
|
startDate: z.string().nullish(),
|
||||||
|
endDate: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resolveDates(
|
||||||
|
projectId: string,
|
||||||
|
input: { range: string; startDate?: string | null; endDate?: string | null }
|
||||||
|
) {
|
||||||
|
const { timezone } = await getSettingsForProject(projectId);
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(
|
||||||
|
{
|
||||||
|
range: input.range as any,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
|
},
|
||||||
|
timezone
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
startDate: startDate.slice(0, 10),
|
||||||
|
endDate: endDate.slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gscRouter = createTRPCRouter({
|
||||||
|
getConnection: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
return db.gscConnection.findUnique({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
siteUrl: true,
|
||||||
|
lastSyncedAt: true,
|
||||||
|
lastSyncStatus: true,
|
||||||
|
lastSyncError: true,
|
||||||
|
backfillStatus: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
initiateOAuth: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Arctic.generateState();
|
||||||
|
const codeVerifier = Arctic.generateCodeVerifier();
|
||||||
|
const url = googleGsc.createAuthorizationURL(state, codeVerifier, [
|
||||||
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||||
|
]);
|
||||||
|
url.searchParams.set('access_type', 'offline');
|
||||||
|
url.searchParams.set('prompt', 'consent');
|
||||||
|
|
||||||
|
const cookieOpts = { maxAge: 60 * 10, signed: true };
|
||||||
|
ctx.setCookie('gsc_oauth_state', state, cookieOpts);
|
||||||
|
ctx.setCookie('gsc_code_verifier', codeVerifier, cookieOpts);
|
||||||
|
ctx.setCookie('gsc_project_id', input.projectId, cookieOpts);
|
||||||
|
|
||||||
|
return { url: url.toString() };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSites: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
return listGscSites(input.projectId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
selectSite: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string(), siteUrl: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await db.gscConnection.findUnique({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
});
|
||||||
|
if (!conn) {
|
||||||
|
throw TRPCNotFoundError('GSC connection not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.gscConnection.update({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
data: {
|
||||||
|
siteUrl: input.siteUrl,
|
||||||
|
backfillStatus: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await gscQueue.add('gscProjectBackfill', {
|
||||||
|
type: 'gscProjectBackfill',
|
||||||
|
payload: { projectId: input.projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
disconnect: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.gscConnection.deleteMany({
|
||||||
|
where: { projectId: input.projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getOverview: protectedProcedure
|
||||||
|
.input(zGscDateInput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
const interval = ['day', 'week', 'month'].includes(input.interval)
|
||||||
|
? (input.interval as 'day' | 'week' | 'month')
|
||||||
|
: 'day';
|
||||||
|
return getGscOverview(input.projectId, startDate, endDate, interval);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPages: protectedProcedure
|
||||||
|
.input(
|
||||||
|
zGscDateInput.extend({
|
||||||
|
limit: z.number().min(1).max(10_000).optional().default(100),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
return getGscPages(input.projectId, startDate, endDate, input.limit);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPageDetails: protectedProcedure
|
||||||
|
.input(zGscDateInput.extend({ page: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
return getGscPageDetails(input.projectId, input.page, startDate, endDate);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getQueryDetails: protectedProcedure
|
||||||
|
.input(zGscDateInput.extend({ query: z.string() }))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
return getGscQueryDetails(
|
||||||
|
input.projectId,
|
||||||
|
input.query,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getQueries: protectedProcedure
|
||||||
|
.input(
|
||||||
|
zGscDateInput.extend({
|
||||||
|
limit: z.number().min(1).max(1000).optional().default(100),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
return getGscQueries(input.projectId, startDate, endDate, input.limit);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSearchEngines: protectedProcedure
|
||||||
|
.input(zGscDateInput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
|
||||||
|
const startMs = new Date(startDate).getTime();
|
||||||
|
const duration = new Date(endDate).getTime() - startMs;
|
||||||
|
const prevEnd = new Date(startMs - 1);
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const [engines, [prevResult]] = await Promise.all([
|
||||||
|
chQuery<{ name: string; sessions: number }>(
|
||||||
|
`SELECT
|
||||||
|
referrer_name as name,
|
||||||
|
count(*) as sessions
|
||||||
|
FROM ${TABLE_NAMES.sessions}
|
||||||
|
WHERE project_id = '${input.projectId}'
|
||||||
|
AND referrer_type = 'search'
|
||||||
|
AND created_at >= '${startDate}'
|
||||||
|
AND created_at <= '${endDate}'
|
||||||
|
GROUP BY referrer_name
|
||||||
|
ORDER BY sessions DESC
|
||||||
|
LIMIT 10`
|
||||||
|
),
|
||||||
|
chQuery<{ sessions: number }>(
|
||||||
|
`SELECT count(*) as sessions
|
||||||
|
FROM ${TABLE_NAMES.sessions}
|
||||||
|
WHERE project_id = '${input.projectId}'
|
||||||
|
AND referrer_type = 'search'
|
||||||
|
AND created_at >= '${fmt(prevStart)}'
|
||||||
|
AND created_at <= '${fmt(prevEnd)}'`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
engines,
|
||||||
|
total: engines.reduce((s, e) => s + e.sessions, 0),
|
||||||
|
previousTotal: prevResult?.sessions ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAiEngines: protectedProcedure
|
||||||
|
.input(zGscDateInput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
|
||||||
|
const startMs = new Date(startDate).getTime();
|
||||||
|
const duration = new Date(endDate).getTime() - startMs;
|
||||||
|
const prevEnd = new Date(startMs - 1);
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Known AI referrer names — will switch to referrer_type = 'ai' once available
|
||||||
|
const aiNames = [
|
||||||
|
'chatgpt.com',
|
||||||
|
'openai.com',
|
||||||
|
'claude.ai',
|
||||||
|
'anthropic.com',
|
||||||
|
'perplexity.ai',
|
||||||
|
'gemini.google.com',
|
||||||
|
'copilot.com',
|
||||||
|
'grok.com',
|
||||||
|
'mistral.ai',
|
||||||
|
'kagi.com',
|
||||||
|
]
|
||||||
|
.map((n) => `'${n}', '${n.replace(/\.[^.]+$/, '')}'`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const where = (start: string, end: string) =>
|
||||||
|
`project_id = '${input.projectId}'
|
||||||
|
AND referrer_name IN (${aiNames})
|
||||||
|
AND created_at >= '${start}'
|
||||||
|
AND created_at <= '${end}'`;
|
||||||
|
|
||||||
|
const [engines, [prevResult]] = await Promise.all([
|
||||||
|
chQuery<{ referrer_name: string; sessions: number }>(
|
||||||
|
`SELECT lower(
|
||||||
|
regexp_replace(referrer_name, '^https?://', '')
|
||||||
|
) as referrer_name, count(*) as sessions
|
||||||
|
FROM ${TABLE_NAMES.sessions}
|
||||||
|
WHERE ${where(startDate, endDate)}
|
||||||
|
GROUP BY referrer_name
|
||||||
|
ORDER BY sessions DESC
|
||||||
|
LIMIT 10`
|
||||||
|
),
|
||||||
|
chQuery<{ sessions: number }>(
|
||||||
|
`SELECT count(*) as sessions
|
||||||
|
FROM ${TABLE_NAMES.sessions}
|
||||||
|
WHERE ${where(fmt(prevStart), fmt(prevEnd))}`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
engines: engines.map((e) => ({
|
||||||
|
name: e.referrer_name,
|
||||||
|
sessions: e.sessions,
|
||||||
|
})),
|
||||||
|
total: engines.reduce((s, e) => s + e.sessions, 0),
|
||||||
|
previousTotal: prevResult?.sessions ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPreviousOverview: protectedProcedure
|
||||||
|
.input(zGscDateInput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
|
||||||
|
const startMs = new Date(startDate).getTime();
|
||||||
|
const duration = new Date(endDate).getTime() - startMs;
|
||||||
|
const prevEnd = new Date(startMs - 1);
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const interval = (['day', 'week', 'month'] as const).includes(
|
||||||
|
input.interval as 'day' | 'week' | 'month'
|
||||||
|
)
|
||||||
|
? (input.interval as 'day' | 'week' | 'month')
|
||||||
|
: 'day';
|
||||||
|
|
||||||
|
return getGscOverview(
|
||||||
|
input.projectId,
|
||||||
|
fmt(prevStart),
|
||||||
|
fmt(prevEnd),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCannibalization: protectedProcedure
|
||||||
|
.input(zGscDateInput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||||
|
return getGscCannibalization(input.projectId, startDate, endDate);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
res.setCookie(key, value, {
|
res.setCookie(key, value, {
|
||||||
maxAge: options.maxAge,
|
maxAge: options.maxAge,
|
||||||
|
signed: options.signed,
|
||||||
...COOKIE_OPTIONS,
|
...COOKIE_OPTIONS,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,5 +112,6 @@ export type ISetCookie = (
|
|||||||
sameSite?: 'lax' | 'strict' | 'none';
|
sameSite?: 'lax' | 'strict' | 'none';
|
||||||
secure?: boolean;
|
secure?: boolean;
|
||||||
httpOnly?: boolean;
|
httpOnly?: boolean;
|
||||||
|
signed?: boolean;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user