feat: added google search console
This commit is contained in:
committed by
GitHub
parent
70ca44f039
commit
271d189ed0
@@ -1,24 +1,27 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import type {
|
||||
IReport,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
IReport,
|
||||
} from '@openpanel/validation';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { ReportChartType } from '../report/ReportChartType';
|
||||
import { ReportInterval } from '../report/ReportInterval';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { TimeWindowPicker } from '../time-window-picker';
|
||||
import { Button } from '../ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
|
||||
export function ChatReport({
|
||||
lazy,
|
||||
...props
|
||||
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
||||
}: {
|
||||
report: IReport & { startDate: string; endDate: string };
|
||||
lazy: boolean;
|
||||
}) {
|
||||
const [chartType, setChartType] = useState<IChartType>(
|
||||
props.report.chartType,
|
||||
props.report.chartType
|
||||
);
|
||||
const [startDate, setStartDate] = useState<string>(props.report.startDate);
|
||||
const [endDate, setEndDate] = useState<string>(props.report.endDate);
|
||||
@@ -35,47 +38,48 @@ export function ChatReport({
|
||||
};
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ReportChart lazy={lazy} report={report} />
|
||||
</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">
|
||||
<TimeWindowPicker
|
||||
className="min-w-0"
|
||||
onChange={setRange}
|
||||
value={report.range}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
endDate={report.endDate}
|
||||
onChange={setRange}
|
||||
onEndDateChange={setEndDate}
|
||||
onIntervalChange={setInterval}
|
||||
onStartDateChange={setStartDate}
|
||||
startDate={report.startDate}
|
||||
value={report.range}
|
||||
/>
|
||||
<ReportInterval
|
||||
chartType={chartType}
|
||||
className="min-w-0"
|
||||
interval={interval}
|
||||
range={range}
|
||||
chartType={chartType}
|
||||
onChange={setInterval}
|
||||
range={range}
|
||||
/>
|
||||
<ReportChartType
|
||||
value={chartType}
|
||||
onChange={(type) => {
|
||||
setChartType(type);
|
||||
}}
|
||||
value={chartType}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={SaveIcon}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pushModal('SaveReport', {
|
||||
report,
|
||||
disableRedirect: true,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Save report
|
||||
</Button>
|
||||
|
||||
@@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||
|
||||
export function OverviewRange() {
|
||||
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
|
||||
useOverviewOptions();
|
||||
const {
|
||||
range,
|
||||
setRange,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
endDate,
|
||||
startDate,
|
||||
setInterval,
|
||||
} = useOverviewOptions();
|
||||
|
||||
return (
|
||||
<TimeWindowPicker
|
||||
onChange={setRange}
|
||||
value={range}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
endDate={endDate}
|
||||
onChange={setRange}
|
||||
onEndDateChange={setEndDate}
|
||||
onIntervalChange={setInterval}
|
||||
onStartDateChange={setStartDate}
|
||||
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",
|
||||
"openai": "https://openai.com",
|
||||
"chatgpt.com": "https://chatgpt.com",
|
||||
"copilot.com": "https://www.copilot.com",
|
||||
"mailchimp": "https://mailchimp.com",
|
||||
"activecampaign": "https://www.activecampaign.com",
|
||||
"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 { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
@@ -14,18 +17,13 @@ import {
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { pushModal } from '@/modals';
|
||||
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 {
|
||||
report: IServiceReport | null;
|
||||
@@ -54,15 +52,15 @@ export default function ReportEditor({
|
||||
return (
|
||||
<Sheet>
|
||||
<div>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<EditReportName />
|
||||
{initialReport?.id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={ShareIcon}
|
||||
onClick={() =>
|
||||
pushModal('ShareReportModal', { reportId: initialReport.id })
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Share
|
||||
</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">
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="self-start"
|
||||
icon={GanttChartSquareIcon}
|
||||
variant="cta"
|
||||
className="self-start"
|
||||
>
|
||||
Pick events
|
||||
</Button>
|
||||
@@ -88,23 +86,26 @@ export default function ReportEditor({
|
||||
/>
|
||||
<TimeWindowPicker
|
||||
className="min-w-0 flex-1"
|
||||
endDate={report.endDate}
|
||||
onChange={(value) => {
|
||||
dispatch(changeDateRanges(value));
|
||||
}}
|
||||
value={report.range}
|
||||
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
||||
onEndDateChange={(date) => dispatch(changeEndDate(date))}
|
||||
endDate={report.endDate}
|
||||
onIntervalChange={(interval) =>
|
||||
dispatch(changeInterval(interval))
|
||||
}
|
||||
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
||||
startDate={report.startDate}
|
||||
value={report.range}
|
||||
/>
|
||||
<ReportInterval
|
||||
chartType={report.chartType}
|
||||
className="min-w-0 flex-1"
|
||||
endDate={report.endDate}
|
||||
interval={report.interval}
|
||||
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
||||
range={report.range}
|
||||
chartType={report.chartType}
|
||||
startDate={report.startDate}
|
||||
endDate={report.endDate}
|
||||
/>
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
@@ -114,7 +115,7 @@ export default function ReportEditor({
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
||||
{report.ready && (
|
||||
<ReportChart report={{ ...report, projectId }} isEditMode />
|
||||
<ReportChart isEditMode report={{ ...report, projectId }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
LayoutPanelTopIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
TrendingUpDownIcon,
|
||||
UndoDotIcon,
|
||||
UserCircleIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -55,10 +57,11 @@ export default function SidebarProjectMenu({
|
||||
label="Insights"
|
||||
/>
|
||||
<SidebarLink href={'/pages'} icon={LayersIcon} label="Pages" />
|
||||
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
|
||||
<SidebarLink href={'/realtime'} icon={Globe2Icon} label="Realtime" />
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<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">
|
||||
Manage
|
||||
</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 {
|
||||
DropdownMenu,
|
||||
@@ -11,24 +17,18 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { pushModal, useOnPushModal } from '@/modals';
|
||||
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 { timeWindows } from '@openpanel/constants';
|
||||
import type { IChartRange } from '@openpanel/validation';
|
||||
import { endOfDay, format, startOfDay } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
value: IChartRange;
|
||||
onChange: (value: IChartRange) => void;
|
||||
onStartDateChange: (date: string) => void;
|
||||
onEndDateChange: (date: string) => void;
|
||||
onIntervalChange: (interval: IInterval) => void;
|
||||
endDate: string | null;
|
||||
startDate: string | null;
|
||||
className?: string;
|
||||
};
|
||||
}
|
||||
export function TimeWindowPicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -36,6 +36,7 @@ export function TimeWindowPicker({
|
||||
onStartDateChange,
|
||||
endDate,
|
||||
onEndDateChange,
|
||||
onIntervalChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const isDateRangerPickerOpen = useRef(false);
|
||||
@@ -46,10 +47,11 @@ export function TimeWindowPicker({
|
||||
|
||||
const handleCustom = useCallback(() => {
|
||||
pushModal('DateRangerPicker', {
|
||||
onChange: ({ startDate, endDate }) => {
|
||||
onChange: ({ startDate, endDate, interval }) => {
|
||||
onStartDateChange(format(startOfDay(startDate), 'yyyy-MM-dd HH:mm:ss'));
|
||||
onEndDateChange(format(endOfDay(endDate), 'yyyy-MM-dd HH:mm:ss'));
|
||||
onChange('custom');
|
||||
onIntervalChange(interval);
|
||||
},
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
@@ -69,7 +71,7 @@ export function TimeWindowPicker({
|
||||
}
|
||||
|
||||
const match = Object.values(timeWindows).find(
|
||||
(tw) => event.key === tw.shortcut.toLowerCase(),
|
||||
(tw) => event.key === tw.shortcut.toLowerCase()
|
||||
);
|
||||
if (match?.key === 'custom') {
|
||||
handleCustom();
|
||||
@@ -84,9 +86,9 @@ export function TimeWindowPicker({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={CalendarIcon}
|
||||
className={cn('justify-start', className)}
|
||||
icon={CalendarIcon}
|
||||
variant="outline"
|
||||
>
|
||||
{timeWindow?.label}
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
} from 'react-day-picker';
|
||||
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -29,99 +28,93 @@ function Calendar({
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
captionLayout={captionLayout}
|
||||
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\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col sm:flex-row relative',
|
||||
defaultClassNames.months,
|
||||
'relative flex flex-col gap-4 sm:flex-row',
|
||||
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(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav,
|
||||
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous,
|
||||
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next,
|
||||
'size-(--cell-size) select-none p-0 aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns,
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm',
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root,
|
||||
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown,
|
||||
'absolute inset-0 bg-popover opacity-0',
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? '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',
|
||||
defaultClassNames.caption_label,
|
||||
: '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
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday,
|
||||
'flex-1 select-none rounded-md font-normal text-[0.8rem] text-muted-foreground',
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header,
|
||||
'w-(--cell-size) select-none',
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number,
|
||||
'select-none text-[0.8rem] text-muted-foreground',
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
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',
|
||||
defaultClassNames.day,
|
||||
'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
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start,
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled,
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
@@ -130,9 +123,9 @@ function Calendar({
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -169,6 +162,12 @@ function Calendar({
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
showOutsideDays={showOutsideDays}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -184,29 +183,31 @@ function CalendarDayButton({
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
if (modifiers.focused) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'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',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
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={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
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,
|
||||
)}
|
||||
ref={ref}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface DataTableProps<TData> {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
onRowClick?: (row: import('@tanstack/react-table').Row<TData>) => void;
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
@@ -35,6 +36,7 @@ export function DataTable<TData>({
|
||||
table,
|
||||
loading,
|
||||
className,
|
||||
onRowClick,
|
||||
empty = {
|
||||
title: 'No data',
|
||||
description: 'We could not find any data here yet',
|
||||
@@ -78,6 +80,8 @@ export function DataTable<TData>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer' : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
Reference in New Issue
Block a user