comments
This commit is contained in:
@@ -4,6 +4,22 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { LogError } from '@/utils/errors';
|
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(
|
export async function gscGoogleCallback(
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
@@ -16,10 +32,10 @@ export async function gscGoogleCallback(
|
|||||||
|
|
||||||
const query = schema.safeParse(req.query);
|
const query = schema.safeParse(req.query);
|
||||||
if (!query.success) {
|
if (!query.success) {
|
||||||
throw new LogError('Invalid GSC callback query params', {
|
throw new LogError(
|
||||||
error: query.error,
|
'Invalid GSC callback query params',
|
||||||
query: req.query,
|
sanitizeOAuthQuery(req.query as Record<string, unknown>)
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code, state } = query.data;
|
const { code, state } = query.data;
|
||||||
@@ -27,16 +43,24 @@ export async function gscGoogleCallback(
|
|||||||
const codeVerifier = req.cookies.gsc_code_verifier ?? null;
|
const codeVerifier = req.cookies.gsc_code_verifier ?? null;
|
||||||
const projectId = req.cookies.gsc_project_id ?? null;
|
const projectId = req.cookies.gsc_project_id ?? null;
|
||||||
|
|
||||||
if (!storedState || !codeVerifier || !projectId) {
|
const hasStoredState = storedState !== null;
|
||||||
|
const hasCodeVerifier = codeVerifier !== null;
|
||||||
|
const hasProjectId = projectId !== null;
|
||||||
|
const hasAllCookies = hasStoredState && hasCodeVerifier && hasProjectId;
|
||||||
|
if (!hasAllCookies) {
|
||||||
throw new LogError('Missing GSC OAuth cookies', {
|
throw new LogError('Missing GSC OAuth cookies', {
|
||||||
storedState: storedState === null,
|
storedState: !hasStoredState,
|
||||||
codeVerifier: codeVerifier === null,
|
codeVerifier: !hasCodeVerifier,
|
||||||
projectId: projectId === null,
|
projectId: !hasProjectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state !== storedState) {
|
if (state !== storedState) {
|
||||||
throw new LogError('GSC OAuth state mismatch', { state, storedState });
|
throw new LogError('GSC OAuth state mismatch', {
|
||||||
|
hasState: true,
|
||||||
|
hasStoredState: true,
|
||||||
|
stateMismatch: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await googleGsc.validateAuthorizationCode(
|
const tokens = await googleGsc.validateAuthorizationCode(
|
||||||
@@ -91,6 +115,9 @@ export async function gscGoogleCallback(
|
|||||||
return reply.redirect(redirectUrl);
|
return reply.redirect(redirectUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.log.error(error);
|
req.log.error(error);
|
||||||
|
reply.clearCookie('gsc_oauth_state');
|
||||||
|
reply.clearCookie('gsc_code_verifier');
|
||||||
|
reply.clearCookie('gsc_project_id');
|
||||||
return redirectWithError(reply, error);
|
return redirectWithError(reply, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
|
|||||||
|
|
||||||
const breakdownKey = type === 'page' ? 'query' : 'page';
|
const breakdownKey = type === 'page' ? 'query' : 'page';
|
||||||
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
const breakdownLabel = type === 'page' ? 'Query' : 'Page';
|
||||||
|
const pluralLabel = type === 'page' ? 'queries' : 'pages';
|
||||||
|
|
||||||
const maxClicks = Math.max(
|
const maxClicks = Math.max(
|
||||||
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
|
||||||
@@ -52,7 +53,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
|
|||||||
return (
|
return (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<h3 className="font-medium text-sm">Top {breakdownLabel.toLowerCase()}s</h3>
|
<h3 className="font-medium text-sm">Top {pluralLabel}</h3>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<OverviewWidgetTable
|
<OverviewWidgetTable
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
|
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Pagination } from '@/components/pagination';
|
import { Pagination } from '@/components/pagination';
|
||||||
import { useAppContext } from '@/hooks/use-app-context';
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
@@ -9,8 +10,8 @@ import { cn } from '@/utils/cn';
|
|||||||
|
|
||||||
interface GscCannibalizationProps {
|
interface GscCannibalizationProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
range: string;
|
range: IChartRange;
|
||||||
interval: string;
|
interval: IInterval;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,13 @@ export function GscCannibalization({
|
|||||||
|
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.gsc.getCannibalization.queryOptions(
|
trpc.gsc.getCannibalization.queryOptions(
|
||||||
{ projectId, range: range as any, interval: interval as any },
|
{
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
{ placeholderData: keepPreviousData }
|
{ placeholderData: keepPreviousData }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -50,6 +57,9 @@ export function GscCannibalization({
|
|||||||
const items = query.data ?? [];
|
const items = query.data ?? [];
|
||||||
|
|
||||||
const pageCount = Math.ceil(items.length / pageSize) || 1;
|
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(
|
const paginatedItems = useMemo(
|
||||||
() => items.slice(page * pageSize, (page + 1) * pageSize),
|
() => items.slice(page * pageSize, (page + 1) * pageSize),
|
||||||
[items, page, pageSize]
|
[items, page, pageSize]
|
||||||
@@ -99,7 +109,6 @@ export function GscCannibalization({
|
|||||||
))}
|
))}
|
||||||
{paginatedItems.map((item) => {
|
{paginatedItems.map((item) => {
|
||||||
const isOpen = expanded.has(item.query);
|
const isOpen = expanded.has(item.query);
|
||||||
const winner = item.pages[0];
|
|
||||||
const avgCtr =
|
const avgCtr =
|
||||||
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
|
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface SparklineBarsProps {
|
|||||||
data: { date: string; pageviews: number }[];
|
data: { date: string; pageviews: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const gap = 1;
|
const defaultGap = 1;
|
||||||
const height = 24;
|
const height = 24;
|
||||||
const width = 100;
|
const width = 100;
|
||||||
|
|
||||||
@@ -38,7 +38,16 @@ function SparklineBars({ data }: SparklineBarsProps) {
|
|||||||
}
|
}
|
||||||
const max = Math.max(...data.map((d) => d.pageviews), 1);
|
const max = Math.max(...data.map((d) => d.pageviews), 1);
|
||||||
const total = data.length;
|
const total = data.length;
|
||||||
const barW = Math.max(2, Math.floor((width - gap * (total - 1)) / total));
|
// 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 trend = getTrendDirection(data);
|
||||||
const trendColor =
|
const trendColor =
|
||||||
trend === '↑'
|
trend === '↑'
|
||||||
@@ -71,9 +80,9 @@ function SparklineBars({ data }: SparklineBarsProps) {
|
|||||||
<Tooltiper
|
<Tooltiper
|
||||||
content={
|
content={
|
||||||
trend === '↑'
|
trend === '↑'
|
||||||
? 'Upgoing trend'
|
? 'Upward trend'
|
||||||
: trend === '↓'
|
: trend === '↓'
|
||||||
? 'Downgoing trend'
|
? 'Downward trend'
|
||||||
: 'Stable trend'
|
: 'Stable trend'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ export function useColumns({
|
|||||||
if (prev == null) {
|
if (prev == null) {
|
||||||
return <span className="text-muted-foreground">—</span>;
|
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 pct = ((row.original.sessions - prev) / prev) * 100;
|
||||||
const isPos = pct >= 0;
|
const isPos = pct >= 0;
|
||||||
@@ -112,15 +122,12 @@ export function useColumns({
|
|||||||
<span className="font-mono text-sm tabular-nums">
|
<span className="font-mono text-sm tabular-nums">
|
||||||
{number.short(row.original.sessions)}
|
{number.short(row.original.sessions)}
|
||||||
</span>
|
</span>
|
||||||
{prev === 0 && <span className="text-muted-foreground">new</span>}
|
|
||||||
{prev > 0 && (
|
|
||||||
<span
|
<span
|
||||||
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||||
>
|
>
|
||||||
{isPos ? '+' : ''}
|
{isPos ? '+' : ''}
|
||||||
{pct.toFixed(1)}%
|
{pct.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ export function PagesTable({ projectId }: PagesTableProps) {
|
|||||||
|
|
||||||
const pagesQuery = useQuery(
|
const pagesQuery = useQuery(
|
||||||
trpc.event.pages.queryOptions(
|
trpc.event.pages.queryOptions(
|
||||||
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval },
|
{
|
||||||
|
projectId,
|
||||||
|
search: debouncedSearch ?? undefined,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
},
|
||||||
{ placeholderData: keepPreviousData },
|
{ placeholderData: keepPreviousData },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -44,7 +49,7 @@ export function PagesTable({ projectId }: PagesTableProps) {
|
|||||||
range,
|
range,
|
||||||
startDate: startDate ?? undefined,
|
startDate: startDate ?? undefined,
|
||||||
endDate: endDate ?? undefined,
|
endDate: endDate ?? undefined,
|
||||||
limit: 1000,
|
limit: 10_000,
|
||||||
},
|
},
|
||||||
{ enabled: isGscConnected },
|
{ enabled: isGscConnected },
|
||||||
),
|
),
|
||||||
@@ -88,22 +93,11 @@ export function PagesTable({ projectId }: PagesTableProps) {
|
|||||||
}));
|
}));
|
||||||
}, [pagesQuery.data, gscMap]);
|
}, [pagesQuery.data, gscMap]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!debouncedSearch) return rawData;
|
|
||||||
const q = debouncedSearch.toLowerCase();
|
|
||||||
return rawData.filter(
|
|
||||||
(p) =>
|
|
||||||
p.path.toLowerCase().includes(q) ||
|
|
||||||
p.origin.toLowerCase().includes(q) ||
|
|
||||||
(p.title ?? '').toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [rawData, debouncedSearch]);
|
|
||||||
|
|
||||||
const columns = useColumns({ projectId, isGscConnected, previousMap });
|
const columns = useColumns({ projectId, isGscConnected, previousMap });
|
||||||
|
|
||||||
const { table } = useTable({
|
const { table } = useTable({
|
||||||
columns,
|
columns,
|
||||||
data: filteredData,
|
data: rawData,
|
||||||
loading: pagesQuery.isLoading,
|
loading: pagesQuery.isLoading,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
name: 'pages',
|
name: 'pages',
|
||||||
@@ -133,7 +127,9 @@ export function PagesTable({ projectId }: PagesTableProps) {
|
|||||||
: 'Integrate our web SDK to your site to get pages here.',
|
: 'Integrate our web SDK to your site to get pages here.',
|
||||||
}}
|
}}
|
||||||
onRowClick={(row) => {
|
onRowClick={(row) => {
|
||||||
if (!isGscConnected) return;
|
if (!isGscConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const page = row.original;
|
const page = row.original;
|
||||||
pushModal('PageDetails', {
|
pushModal('PageDetails', {
|
||||||
type: 'page',
|
type: 'page',
|
||||||
|
|||||||
@@ -23,16 +23,28 @@ import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
|
||||||
type GscChartData = { date: string; clicks: number; impressions: number };
|
interface GscChartData {
|
||||||
|
date: string;
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
}
|
||||||
|
interface GscViewsChartData {
|
||||||
|
date: string;
|
||||||
|
views: number;
|
||||||
|
}
|
||||||
|
|
||||||
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||||
GscChartData,
|
GscChartData | GscViewsChartData,
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>(({ data }) => {
|
>(({ data }) => {
|
||||||
const item = data[0];
|
const item = data[0];
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!('date' in item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ('views' in item && item.views != null) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ChartTooltipHeader>
|
<ChartTooltipHeader>
|
||||||
@@ -40,16 +52,36 @@ const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
|||||||
</ChartTooltipHeader>
|
</ChartTooltipHeader>
|
||||||
<ChartTooltipItem color={getChartColor(0)}>
|
<ChartTooltipItem color={getChartColor(0)}>
|
||||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
<span>Clicks</span>
|
<span>Views</span>
|
||||||
<span>{item.clicks.toLocaleString()}</span>
|
<span>{item.views.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</ChartTooltipItem>
|
</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)}>
|
<ChartTooltipItem color={getChartColor(1)}>
|
||||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
<span>Impressions</span>
|
<span>Impressions</span>
|
||||||
<span>{item.impressions.toLocaleString()}</span>
|
<span>{impressions.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</ChartTooltipItem>
|
</ChartTooltipItem>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -93,10 +125,25 @@ export default function GscDetails(props: Props) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const pagesTimeseriesQuery = useQuery(
|
const { origin: pageOrigin, path: pagePath } =
|
||||||
trpc.event.pagesTimeseries.queryOptions(
|
type === 'page'
|
||||||
{ projectId, ...dateInput },
|
? (() => {
|
||||||
{ enabled: 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 }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +152,7 @@ export default function GscDetails(props: Props) {
|
|||||||
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
|
||||||
|
|
||||||
const timeseries = data?.timeseries ?? [];
|
const timeseries = data?.timeseries ?? [];
|
||||||
const pagesTimeseries = pagesTimeseriesQuery.data ?? [];
|
const pageTimeseries = pageTimeseriesQuery.data ?? [];
|
||||||
const breakdownRows =
|
const breakdownRows =
|
||||||
type === 'page'
|
type === 'page'
|
||||||
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
|
? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
|
||||||
@@ -131,13 +178,14 @@ export default function GscDetails(props: Props) {
|
|||||||
{type === 'page' && (
|
{type === 'page' && (
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
|
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
|
||||||
{isLoading ? (
|
{isLoading || pageTimeseriesQuery.isLoading ? (
|
||||||
<Skeleton className="h-40 w-full" />
|
<Skeleton className="h-40 w-full" />
|
||||||
) : (
|
) : (
|
||||||
<GscViewsChart
|
<GscViewsChart
|
||||||
data={pagesTimeseries
|
data={pageTimeseries.map((r) => ({
|
||||||
.filter((r) => r.origin + r.path === value)
|
date: r.date,
|
||||||
.map((r) => ({ date: r.date, views: r.pageviews }))}
|
views: r.pageviews,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,14 +21,17 @@ export default function PageDetails({ type, projectId, value }: Props) {
|
|||||||
<div className="col gap-6">
|
<div className="col gap-6">
|
||||||
{type === 'page' &&
|
{type === 'page' &&
|
||||||
(() => {
|
(() => {
|
||||||
let origin = value;
|
let origin: string;
|
||||||
let path = '/';
|
let path: string;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
origin = url.origin;
|
origin = url.origin;
|
||||||
path = url.pathname + url.search;
|
path = url.pathname + url.search;
|
||||||
} catch {
|
} catch {
|
||||||
// value might already be just a path
|
// value is path-only (e.g. "/docs/foo")
|
||||||
|
origin =
|
||||||
|
typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
path = value;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<PageViewsChart
|
<PageViewsChart
|
||||||
|
|||||||
@@ -407,7 +407,8 @@ export const getGscCannibalization = cacheable(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
existing.clicks += row.clicks;
|
existing.clicks += row.clicks;
|
||||||
existing.impressions += row.impressions;
|
existing.impressions += row.impressions;
|
||||||
existing.ctr = (existing.ctr + row.ctr) / 2;
|
existing.ctr =
|
||||||
|
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
|
||||||
existing.position = Math.min(existing.position, row.position);
|
existing.position = Math.min(existing.position, row.position);
|
||||||
} else {
|
} else {
|
||||||
entry.pages.push({
|
entry.pages.push({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface IGetPagesInput {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageTimeseriesRow {
|
export interface IPageTimeseriesRow {
|
||||||
@@ -37,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)
|
||||||
@@ -92,12 +94,18 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 @@ export const eventRouter = createTRPCRouter({
|
|||||||
endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
search: input.search,
|
search: input.search,
|
||||||
|
limit: input.take,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const gscRouter = createTRPCRouter({
|
|||||||
getPages: protectedProcedure
|
getPages: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
zGscDateInput.extend({
|
zGscDateInput.extend({
|
||||||
limit: z.number().min(1).max(1000).optional().default(100),
|
limit: z.number().min(1).max(10_000).optional().default(100),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user