feat: added google search console

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-09 20:47:02 +01:00
committed by GitHub
parent 70ca44f039
commit 271d189ed0
51 changed files with 5471 additions and 503 deletions

View File

@@ -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 { Calendar } from '@/components/ui/calendar';
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 { CheckIcon, XIcon } from 'lucide-react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
onChange: (payload: { startDate: Date; endDate: Date }) => void;
interface Props {
onChange: (payload: {
startDate: Date;
endDate: Date;
interval: IInterval;
}) => void;
startDate?: Date;
endDate?: Date;
};
}
export default function DateRangerPicker({
onChange,
startDate: initialStartDate,
@@ -25,20 +29,20 @@ export default function DateRangerPicker({
const [endDate, setEndDate] = useState(initialEndDate);
return (
<ModalContent className="p-4 md:p-8 min-w-fit">
<ModalContent className="min-w-fit p-4 md:p-8">
<Calendar
captionLayout="dropdown"
initialFocus
mode="range"
className="mx-auto min-h-[310px] p-0 [&_table]:mx-auto [&_table]:w-auto"
defaultMonth={subMonths(
startDate ? new Date(startDate) : new Date(),
isBelowSm ? 0 : 1,
isBelowSm ? 0 : 1
)}
selected={{
from: startDate,
to: endDate,
hidden={{
after: endOfDay(new Date()),
}}
toDate={new Date()}
initialFocus
mode="range"
numberOfMonths={isBelowSm ? 1 : 2}
onSelect={(range) => {
if (range?.from) {
setStartDate(range.from);
@@ -47,33 +51,39 @@ export default function DateRangerPicker({
setEndDate(range.to);
}
}}
numberOfMonths={isBelowSm ? 1 : 2}
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0"
selected={{
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
icon={XIcon}
onClick={() => popModal()}
type="button"
variant="outline"
onClick={() => popModal()}
icon={XIcon}
>
Cancel
</Button>
{startDate && endDate && (
<Button
type="button"
className="md:ml-auto"
icon={startDate && endDate ? CheckIcon : XIcon}
onClick={() => {
popModal();
if (startDate && endDate) {
onChange({
startDate: startDate,
endDate: endDate,
startDate,
endDate,
interval: getDefaultIntervalByDates(
startDate.toISOString(),
endDate.toISOString()
)!,
});
}
}}
icon={startDate && endDate ? CheckIcon : XIcon}
type="button"
>
{startDate && endDate
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`

View 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>
);
}

View File

@@ -1,3 +1,4 @@
import PageDetails from './page-details';
import { createPushModal } from 'pushmodal';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
@@ -34,6 +35,7 @@ import OverviewTopPagesModal from '@/components/overview/overview-top-pages-moda
import { op } from '@/utils/op';
const modals = {
PageDetails,
OverviewTopPagesModal,
OverviewTopGenericModal,
RequestPasswordReset,

View 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>
);
}