feat: added google search console
This commit is contained in:
committed by
GitHub
parent
70ca44f039
commit
271d189ed0
@@ -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)}`
|
||||
|
||||
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 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,
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user