feat: added google search console
This commit is contained in:
committed by
GitHub
parent
70ca44f039
commit
271d189ed0
@@ -1,349 +1,22 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { PagesTable } from '@/components/pages/table';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { FloatingPagination } from '@/components/pagination-floating';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TableButtons } from '@/components/ui/table';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import type { IChartRange, IInterval } from '@openpanel/validation';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
|
||||
component: Component,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle(PAGE_TITLES.PAGES),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle(PAGE_TITLES.PAGES) }],
|
||||
}),
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const take = 20;
|
||||
const { range, interval } = useOverviewOptions();
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
|
||||
const { debouncedSearch, setSearch, search } = useSearchQueryState();
|
||||
|
||||
// Track if we should use backend search (when client-side filtering finds nothing)
|
||||
const [useBackendSearch, setUseBackendSearch] = useState(false);
|
||||
|
||||
// Reset to client-side filtering when search changes
|
||||
useEffect(() => {
|
||||
setUseBackendSearch(false);
|
||||
setCursor(1);
|
||||
}, [debouncedSearch, setCursor]);
|
||||
|
||||
// Query for all pages (without search) - used for client-side filtering
|
||||
const allPagesQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
cursor: 1,
|
||||
take: 1000,
|
||||
search: undefined, // No search - get all pages
|
||||
range,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Query for backend search (only when client-side filtering finds nothing)
|
||||
const backendSearchQuery = useQuery(
|
||||
trpc.event.pages.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
cursor: 1,
|
||||
take: 1000,
|
||||
search: debouncedSearch || undefined,
|
||||
range,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: useBackendSearch && !!debouncedSearch,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Client-side filtering: filter all pages by search query
|
||||
const clientSideFiltered = useMemo(() => {
|
||||
if (!debouncedSearch || useBackendSearch) {
|
||||
return allPagesQuery.data ?? [];
|
||||
}
|
||||
const searchLower = debouncedSearch.toLowerCase();
|
||||
return (allPagesQuery.data ?? []).filter(
|
||||
(page) =>
|
||||
page.path.toLowerCase().includes(searchLower) ||
|
||||
page.origin.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [allPagesQuery.data, debouncedSearch, useBackendSearch]);
|
||||
|
||||
// Check if client-side filtering found results
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedSearch &&
|
||||
!useBackendSearch &&
|
||||
allPagesQuery.isSuccess &&
|
||||
clientSideFiltered.length === 0
|
||||
) {
|
||||
// No results from client-side filtering, switch to backend search
|
||||
setUseBackendSearch(true);
|
||||
}
|
||||
}, [
|
||||
debouncedSearch,
|
||||
useBackendSearch,
|
||||
allPagesQuery.isSuccess,
|
||||
clientSideFiltered.length,
|
||||
]);
|
||||
|
||||
// Determine which data source to use
|
||||
const allData = useBackendSearch
|
||||
? (backendSearchQuery.data ?? [])
|
||||
: clientSideFiltered;
|
||||
|
||||
const isLoading = useBackendSearch
|
||||
? backendSearchQuery.isLoading
|
||||
: allPagesQuery.isLoading;
|
||||
|
||||
// Client-side pagination: slice the items based on cursor
|
||||
const startIndex = (cursor - 1) * take;
|
||||
const endIndex = startIndex + take;
|
||||
const data = allData.slice(startIndex, endIndex);
|
||||
const totalPages = Math.ceil(allData.length / take);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Pages"
|
||||
description="Access all your pages here"
|
||||
className="mb-8"
|
||||
/>
|
||||
<TableButtons>
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
<Input
|
||||
className="self-auto"
|
||||
placeholder="Search path"
|
||||
value={search ?? ''}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setCursor(1);
|
||||
}}
|
||||
/>
|
||||
</TableButtons>
|
||||
{data.length === 0 && !isLoading && (
|
||||
<FullPageEmptyState
|
||||
title="No pages"
|
||||
description={
|
||||
debouncedSearch
|
||||
? `No pages found matching "${debouncedSearch}"`
|
||||
: 'Integrate our web sdk to your site to get pages here.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<PageCardSkeleton />
|
||||
<PageCardSkeleton />
|
||||
<PageCardSkeleton />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.map((page) => {
|
||||
return (
|
||||
<PageCard
|
||||
key={page.origin + page.path}
|
||||
page={page}
|
||||
range={range}
|
||||
interval={interval}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allData.length !== 0 && (
|
||||
<div className="p-4">
|
||||
<FloatingPagination
|
||||
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
|
||||
canNextPage={cursor < totalPages}
|
||||
canPreviousPage={cursor > 1}
|
||||
pageIndex={cursor - 1}
|
||||
nextPage={() => {
|
||||
setCursor((p) => Math.min(p + 1, totalPages));
|
||||
}}
|
||||
previousPage={() => {
|
||||
setCursor((p) => Math.max(p - 1, 1));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PageHeader title="Pages" description="Access all your pages here" className="mb-8" />
|
||||
<PagesTable projectId={projectId} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PageCard = memo(
|
||||
({
|
||||
page,
|
||||
range,
|
||||
interval,
|
||||
projectId,
|
||||
}: {
|
||||
page: RouterOutputs['event']['pages'][number];
|
||||
range: IChartRange;
|
||||
interval: IInterval;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const number = useNumber();
|
||||
const { apiUrl } = useAppContext();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
||||
<div className="row gap-2 items-center h-16">
|
||||
<img
|
||||
src={`${apiUrl}/misc/og?url=${page.origin}${page.path}`}
|
||||
alt={page.title}
|
||||
className="size-10 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="col min-w-0">
|
||||
<div className="font-medium leading-[28px] truncate">
|
||||
{page.title}
|
||||
</div>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`${page.origin}${page.path}`}
|
||||
className="text-muted-foreground font-mono truncate hover:underline"
|
||||
>
|
||||
{page.path}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row border-y">
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<div className="font-medium text-xl font-mono">
|
||||
{number.formatWithUnit(page.avg_duration, 'min')}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||
duration
|
||||
</div>
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<div className="font-medium text-xl font-mono">
|
||||
{number.formatWithUnit(page.bounce_rate / 100, '%')}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||
bounce rate
|
||||
</div>
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<div className="font-medium text-xl font-mono">
|
||||
{number.format(page.sessions)}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-nowrap text-sm">
|
||||
sessions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReportChart
|
||||
options={{
|
||||
hideXAxis: true,
|
||||
hideYAxis: true,
|
||||
aspectRatio: 0.15,
|
||||
}}
|
||||
report={{
|
||||
breakdowns: [],
|
||||
metric: 'sum',
|
||||
range,
|
||||
interval,
|
||||
previous: true,
|
||||
chartType: 'linear',
|
||||
projectId,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'path',
|
||||
name: 'path',
|
||||
value: [page.path],
|
||||
operator: 'is',
|
||||
},
|
||||
{
|
||||
id: 'origin',
|
||||
name: 'origin',
|
||||
value: [page.origin],
|
||||
operator: 'is',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const PageCardSkeleton = memo(() => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row gap-4 justify-between p-4 py-2 items-center">
|
||||
<div className="row gap-2 items-center h-16">
|
||||
<Skeleton className="size-10 rounded-sm" />
|
||||
<div className="col min-w-0">
|
||||
<Skeleton className="h-3 w-32 mb-2" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row border-y">
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<Skeleton className="h-6 w-16 mb-1" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<Skeleton className="h-6 w-12 mb-1" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="center-center col flex-1 p-4 py-2">
|
||||
<Skeleton className="h-6 w-14 mb-1" />
|
||||
<Skeleton className="h-4 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Skeleton className="h-16 w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
821
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { OverviewWidgetTable } from '@/components/overview/overview-widget-table';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { GscCannibalization } from '@/components/page/gsc-cannibalization';
|
||||
import { GscCtrBenchmark } from '@/components/page/gsc-ctr-benchmark';
|
||||
import { GscPositionChart } from '@/components/page/gsc-position-chart';
|
||||
import { PagesInsights } from '@/components/page/pages-insights';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import {
|
||||
useYAxisProps,
|
||||
X_AXIS_STYLE_PROPS,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/seo')({
|
||||
component: SeoPage,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('SEO') }],
|
||||
}),
|
||||
});
|
||||
|
||||
interface GscChartData {
|
||||
date: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
|
||||
GscChartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
const item = data[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div>{item.date}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Clicks</span>
|
||||
<span>{item.clicks.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
<ChartTooltipItem color={getChartColor(1)}>
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<span>Impressions</span>
|
||||
<span>{item.impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function SeoPage() {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const navigate = useNavigate();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const dateInput = {
|
||||
range,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const isConnected = connection?.siteUrl;
|
||||
|
||||
const overviewQuery = useQuery(
|
||||
trpc.gsc.getOverview.queryOptions(
|
||||
{ projectId, ...dateInput, interval: interval ?? 'day' },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const pagesQuery = useQuery(
|
||||
trpc.gsc.getPages.queryOptions(
|
||||
{ projectId, ...dateInput, limit: 50 },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const queriesQuery = useQuery(
|
||||
trpc.gsc.getQueries.queryOptions(
|
||||
{ projectId, ...dateInput, limit: 50 },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const searchEnginesQuery = useQuery(
|
||||
trpc.gsc.getSearchEngines.queryOptions({ projectId, ...dateInput })
|
||||
);
|
||||
|
||||
const aiEnginesQuery = useQuery(
|
||||
trpc.gsc.getAiEngines.queryOptions({ projectId, ...dateInput })
|
||||
);
|
||||
|
||||
const previousOverviewQuery = useQuery(
|
||||
trpc.gsc.getPreviousOverview.queryOptions(
|
||||
{ projectId, ...dateInput, interval: interval ?? 'day' },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const [pagesPage, setPagesPage] = useState(0);
|
||||
const [queriesPage, setQueriesPage] = useState(0);
|
||||
const pageSize = 15;
|
||||
|
||||
const [pagesSearch, setPagesSearch] = useState('');
|
||||
const [queriesSearch, setQueriesSearch] = useState('');
|
||||
|
||||
const pages = pagesQuery.data ?? [];
|
||||
const queries = queriesQuery.data ?? [];
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
if (!pagesSearch.trim()) {
|
||||
return pages;
|
||||
}
|
||||
const q = pagesSearch.toLowerCase();
|
||||
return pages.filter((row) => {
|
||||
return String(row.page).toLowerCase().includes(q);
|
||||
});
|
||||
}, [pages, pagesSearch]);
|
||||
|
||||
const filteredQueries = useMemo(() => {
|
||||
if (!queriesSearch.trim()) {
|
||||
return queries;
|
||||
}
|
||||
const q = queriesSearch.toLowerCase();
|
||||
return queries.filter((row) => {
|
||||
return String(row.query).toLowerCase().includes(q);
|
||||
});
|
||||
}, [queries, queriesSearch]);
|
||||
|
||||
const paginatedPages = useMemo(
|
||||
() => filteredPages.slice(pagesPage * pageSize, (pagesPage + 1) * pageSize),
|
||||
[filteredPages, pagesPage, pageSize]
|
||||
);
|
||||
|
||||
const paginatedQueries = useMemo(
|
||||
() =>
|
||||
filteredQueries.slice(
|
||||
queriesPage * pageSize,
|
||||
(queriesPage + 1) * pageSize
|
||||
),
|
||||
[filteredQueries, queriesPage, pageSize]
|
||||
);
|
||||
|
||||
const pagesPageCount = Math.ceil(filteredPages.length / pageSize) || 1;
|
||||
const queriesPageCount = Math.ceil(filteredQueries.length / pageSize) || 1;
|
||||
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader description="Google Search Console data" title="SEO" />
|
||||
<div className="mt-8 space-y-4">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<FullPageEmptyState
|
||||
className="pt-[20vh]"
|
||||
description="Connect Google Search Console to track your search impressions, clicks, and keyword rankings."
|
||||
icon={SearchIcon}
|
||||
title="No SEO data yet"
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/settings/gsc',
|
||||
params: { organizationId, projectId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Connect Google Search Console
|
||||
</Button>
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data ?? [];
|
||||
const prevOverview = previousOverviewQuery.data ?? [];
|
||||
|
||||
const sumOverview = (rows: typeof overview) =>
|
||||
rows.reduce(
|
||||
(acc, row) => ({
|
||||
clicks: acc.clicks + row.clicks,
|
||||
impressions: acc.impressions + row.impressions,
|
||||
ctr: acc.ctr + row.ctr,
|
||||
position: acc.position + row.position,
|
||||
}),
|
||||
{ clicks: 0, impressions: 0, ctr: 0, position: 0 }
|
||||
);
|
||||
|
||||
const totals = sumOverview(overview);
|
||||
const prevTotals = sumOverview(prevOverview);
|
||||
const n = Math.max(overview.length, 1);
|
||||
const pn = Math.max(prevOverview.length, 1);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
</>
|
||||
}
|
||||
description={`Search performance for ${connection.siteUrl}`}
|
||||
title="SEO"
|
||||
/>
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||
<div className="card col-span-1 grid grid-cols-2 overflow-hidden rounded-md lg:col-span-2">
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({ current: r.clicks, date: r.date }))}
|
||||
id="clicks"
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Clicks"
|
||||
metric={{ current: totals.clicks, previous: prevTotals.clicks }}
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({
|
||||
current: r.impressions,
|
||||
date: r.date,
|
||||
}))}
|
||||
id="impressions"
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Impressions"
|
||||
metric={{
|
||||
current: totals.impressions,
|
||||
previous: prevTotals.impressions,
|
||||
}}
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({
|
||||
current: r.ctr * 100,
|
||||
date: r.date,
|
||||
}))}
|
||||
id="ctr"
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Avg CTR"
|
||||
metric={{
|
||||
current: (totals.ctr / n) * 100,
|
||||
previous: (prevTotals.ctr / pn) * 100,
|
||||
}}
|
||||
unit="%"
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={overview.map((r) => ({
|
||||
current: r.position,
|
||||
date: r.date,
|
||||
}))}
|
||||
id="position"
|
||||
inverted
|
||||
isLoading={overviewQuery.isLoading}
|
||||
label="Avg Position"
|
||||
metric={{
|
||||
current: totals.position / n,
|
||||
previous: prevTotals.position / pn,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SearchEngines
|
||||
engines={searchEnginesQuery.data?.engines ?? []}
|
||||
isLoading={searchEnginesQuery.isLoading}
|
||||
previousTotal={searchEnginesQuery.data?.previousTotal ?? 0}
|
||||
total={searchEnginesQuery.data?.total ?? 0}
|
||||
/>
|
||||
<AiEngines
|
||||
engines={aiEnginesQuery.data?.engines ?? []}
|
||||
isLoading={aiEnginesQuery.isLoading}
|
||||
previousTotal={aiEnginesQuery.data?.previousTotal ?? 0}
|
||||
total={aiEnginesQuery.data?.total ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GscChart data={overview} isLoading={overviewQuery.isLoading} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<GscPositionChart
|
||||
data={overview}
|
||||
isLoading={overviewQuery.isLoading}
|
||||
/>
|
||||
<GscCtrBenchmark
|
||||
data={pagesQuery.data ?? []}
|
||||
isLoading={pagesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<GscTable
|
||||
isLoading={pagesQuery.isLoading}
|
||||
keyField="page"
|
||||
keyLabel="Page"
|
||||
maxClicks={Math.max(...paginatedPages.map((p) => p.clicks), 1)}
|
||||
onNextPage={() =>
|
||||
setPagesPage((p) => Math.min(pagesPageCount - 1, p + 1))
|
||||
}
|
||||
onPreviousPage={() => setPagesPage((p) => Math.max(0, p - 1))}
|
||||
onRowClick={(value) =>
|
||||
pushModal('PageDetails', { type: 'page', projectId, value })
|
||||
}
|
||||
onSearchChange={(v) => {
|
||||
setPagesSearch(v);
|
||||
setPagesPage(0);
|
||||
}}
|
||||
pageCount={pagesPageCount}
|
||||
pageIndex={pagesPage}
|
||||
pageSize={pageSize}
|
||||
rows={paginatedPages}
|
||||
searchPlaceholder="Search pages"
|
||||
searchValue={pagesSearch}
|
||||
title="Top pages"
|
||||
totalCount={filteredPages.length}
|
||||
/>
|
||||
<GscTable
|
||||
isLoading={queriesQuery.isLoading}
|
||||
keyField="query"
|
||||
keyLabel="Query"
|
||||
maxClicks={Math.max(...paginatedQueries.map((q) => q.clicks), 1)}
|
||||
onNextPage={() =>
|
||||
setQueriesPage((p) => Math.min(queriesPageCount - 1, p + 1))
|
||||
}
|
||||
onPreviousPage={() => setQueriesPage((p) => Math.max(0, p - 1))}
|
||||
onRowClick={(value) =>
|
||||
pushModal('PageDetails', { type: 'query', projectId, value })
|
||||
}
|
||||
onSearchChange={(v) => {
|
||||
setQueriesSearch(v);
|
||||
setQueriesPage(0);
|
||||
}}
|
||||
pageCount={queriesPageCount}
|
||||
pageIndex={queriesPage}
|
||||
pageSize={pageSize}
|
||||
rows={paginatedQueries}
|
||||
searchPlaceholder="Search queries"
|
||||
searchValue={queriesSearch}
|
||||
title="Top queries"
|
||||
totalCount={filteredQueries.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<GscCannibalization
|
||||
endDate={endDate ?? undefined}
|
||||
interval={interval ?? 'day'}
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
startDate={startDate ?? undefined}
|
||||
/>
|
||||
<PagesInsights projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficSourceWidget({
|
||||
title,
|
||||
engines,
|
||||
total,
|
||||
previousTotal,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
}: {
|
||||
title: string;
|
||||
engines: Array<{ name: string; sessions: number }>;
|
||||
total: number;
|
||||
previousTotal: number;
|
||||
isLoading: boolean;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const displayed =
|
||||
engines.length > 8
|
||||
? [
|
||||
...engines.slice(0, 7),
|
||||
{
|
||||
name: 'Others',
|
||||
sessions: engines.slice(7).reduce((s, d) => s + d.sessions, 0),
|
||||
},
|
||||
]
|
||||
: engines.slice(0, 8);
|
||||
|
||||
const max = displayed[0]?.sessions ?? 1;
|
||||
const pctChange =
|
||||
previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : null;
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium font-mono text-sm tabular-nums">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
{pctChange !== null && (
|
||||
<span
|
||||
className={`font-mono text-xs tabular-nums ${pctChange >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
{pctChange >= 0 ? '+' : ''}
|
||||
{pctChange.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{isLoading &&
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5" key={i}>
|
||||
<div className="size-4 animate-pulse rounded-sm bg-muted" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="ml-auto h-3 w-8 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && engines.length === 0 && (
|
||||
<p className="col-span-2 px-4 py-6 text-center text-muted-foreground text-xs">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
displayed.map((engine) => {
|
||||
const pct = total > 0 ? (engine.sessions / total) * 100 : 0;
|
||||
const barPct = (engine.sessions / max) * 100;
|
||||
return (
|
||||
<div className="relative px-4 py-2.5" key={engine.name}>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-muted/50"
|
||||
style={{ width: `${barPct}%` }}
|
||||
/>
|
||||
<div className="relative flex items-center gap-2">
|
||||
{engine.name !== 'Others' && (
|
||||
<SerieIcon
|
||||
className="size-3.5 shrink-0 rounded-sm"
|
||||
name={engine.name}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs capitalize">
|
||||
{engine.name.replace(/\..+$/, '')}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums">
|
||||
{engine.sessions.toLocaleString()}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchEngines(props: {
|
||||
engines: Array<{ name: string; sessions: number }>;
|
||||
total: number;
|
||||
previousTotal: number;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TrafficSourceWidget
|
||||
{...props}
|
||||
emptyMessage="No search traffic in this period"
|
||||
title="Search engines"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AiEngines(props: {
|
||||
engines: Array<{ name: string; sessions: number }>;
|
||||
total: number;
|
||||
previousTotal: number;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TrafficSourceWidget
|
||||
{...props}
|
||||
emptyMessage="No AI traffic in this period"
|
||||
title="AI referrals"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GscChart({
|
||||
data,
|
||||
isLoading,
|
||||
}: {
|
||||
data: Array<{ date: string; clicks: number; impressions: number }>;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const color = getChartColor(0);
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="mb-4 font-medium text-sm">Clicks & Impressions</h3>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<ResponsiveContainer height={200} width="100%">
|
||||
<ComposedChart data={data}>
|
||||
<defs>
|
||||
<filter
|
||||
height="140%"
|
||||
id="gsc-line-glow"
|
||||
width="140%"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
>
|
||||
<feGaussianBlur result="blur" stdDeviation="5" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA slope="0.5" type="linear" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
{...X_AXIS_STYLE_PROPS}
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
type="category"
|
||||
/>
|
||||
<YAxis {...yAxisProps} yAxisId="left" />
|
||||
<YAxis {...yAxisProps} orientation="right" yAxisId="right" />
|
||||
<GscTooltip />
|
||||
<Line
|
||||
dataKey="clicks"
|
||||
dot={false}
|
||||
filter="url(#gsc-line-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
dataKey="impressions"
|
||||
dot={false}
|
||||
filter="url(#gsc-line-glow)"
|
||||
isAnimationActive={false}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="right"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GscTableRow {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
function GscTable({
|
||||
title,
|
||||
rows,
|
||||
keyField,
|
||||
keyLabel,
|
||||
maxClicks,
|
||||
isLoading,
|
||||
onRowClick,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
totalCount,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: {
|
||||
title: string;
|
||||
rows: GscTableRow[];
|
||||
keyField: string;
|
||||
keyLabel: string;
|
||||
maxClicks: number;
|
||||
isLoading: boolean;
|
||||
onRowClick?: (value: string) => void;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
totalCount?: number;
|
||||
pageIndex?: number;
|
||||
pageSize?: number;
|
||||
pageCount?: number;
|
||||
onPreviousPage?: () => void;
|
||||
onNextPage?: () => void;
|
||||
}) {
|
||||
const showPagination =
|
||||
totalCount != null &&
|
||||
pageSize != null &&
|
||||
pageCount != null &&
|
||||
onPreviousPage != null &&
|
||||
onNextPage != null &&
|
||||
pageIndex != null;
|
||||
const canPreviousPage = (pageIndex ?? 0) > 0;
|
||||
const canNextPage = (pageIndex ?? 0) < (pageCount ?? 1) - 1;
|
||||
const rangeStart = totalCount ? (pageIndex ?? 0) * (pageSize ?? 0) + 1 : 0;
|
||||
const rangeEnd = Math.min(
|
||||
(pageIndex ?? 0) * (pageSize ?? 0) + (pageSize ?? 0),
|
||||
totalCount ?? 0
|
||||
);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
</div>
|
||||
<OverviewWidgetTable
|
||||
columns={[
|
||||
{
|
||||
name: keyLabel,
|
||||
width: 'w-full',
|
||||
render: () => <Skeleton className="h-4 w-2/3" />,
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
render: () => <Skeleton className="h-4 w-10" />,
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
render: () => <Skeleton className="h-4 w-8" />,
|
||||
},
|
||||
]}
|
||||
data={[1, 2, 3, 4, 5]}
|
||||
getColumnPercentage={() => 0}
|
||||
keyExtractor={(i) => String(i)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="border-b">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{showPagination && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-muted-foreground text-xs">
|
||||
{totalCount === 0
|
||||
? '0 results'
|
||||
: `${rangeStart}-${rangeEnd} of ${totalCount}`}
|
||||
</span>
|
||||
<Pagination
|
||||
canNextPage={canNextPage}
|
||||
canPreviousPage={canPreviousPage}
|
||||
nextPage={onNextPage}
|
||||
pageIndex={pageIndex}
|
||||
previousPage={onPreviousPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onSearchChange != null && (
|
||||
<div className="relative border-t">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="rounded-none border-0 border-y bg-transparent pl-9 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0"
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder ?? 'Search'}
|
||||
type="search"
|
||||
value={searchValue ?? ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<OverviewWidgetTable
|
||||
columns={[
|
||||
{
|
||||
name: keyLabel,
|
||||
width: 'w-full',
|
||||
render(item) {
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<button
|
||||
className="block w-full truncate text-left font-mono text-xs hover:underline"
|
||||
onClick={() => onRowClick?.(String(item[keyField]))}
|
||||
type="button"
|
||||
>
|
||||
{String(item[keyField])}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clicks',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.clicks,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{item.clicks.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Impr.',
|
||||
width: '70px',
|
||||
getSortValue: (item) => item.impressions,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{item.impressions.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CTR',
|
||||
width: '60px',
|
||||
getSortValue: (item) => item.ctr,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{(item.ctr * 100).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pos.',
|
||||
width: '55px',
|
||||
getSortValue: (item) => item.position,
|
||||
render(item) {
|
||||
return (
|
||||
<span className="font-mono font-semibold text-xs">
|
||||
{item.position.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={rows}
|
||||
getColumnPercentage={(item) => item.clicks / maxClicks}
|
||||
keyExtractor={(item) => String(item[keyField])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||
)({
|
||||
component: GscSettings,
|
||||
});
|
||||
|
||||
function GscSettings() {
|
||||
const { projectId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedSite, setSelectedSite] = useState('');
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions(
|
||||
{ projectId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
);
|
||||
|
||||
const sitesQuery = useQuery(
|
||||
trpc.gsc.getSites.queryOptions(
|
||||
{ projectId },
|
||||
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
|
||||
)
|
||||
);
|
||||
|
||||
const initiateOAuth = useMutation(
|
||||
trpc.gsc.initiateOAuth.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.url;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to initiate Google Search Console connection');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const selectSite = useMutation(
|
||||
trpc.gsc.selectSite.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success('Site connected', {
|
||||
description: 'Backfill of 6 months of data has started.',
|
||||
});
|
||||
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to select site');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const disconnect = useMutation(
|
||||
trpc.gsc.disconnect.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success('Disconnected from Google Search Console');
|
||||
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to disconnect');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not connected at all
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Connect your Google Search Console property to import search
|
||||
performance data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You will be redirected to Google to authorize access. Only read-only
|
||||
access to Search Console data is requested.
|
||||
</p>
|
||||
<Button
|
||||
className="w-fit"
|
||||
disabled={initiateOAuth.isPending}
|
||||
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||
>
|
||||
{initiateOAuth.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Connect Google Search Console
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected but no site selected yet
|
||||
if (!connection.siteUrl) {
|
||||
const sites = sitesQuery.data ?? [];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Select a property</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Choose which Google Search Console property to connect to this
|
||||
project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
{sitesQuery.isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : sites.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No Search Console properties found for this Google account.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Select onValueChange={setSelectedSite} value={selectedSite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a property..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sites.map((site) => (
|
||||
<SelectItem key={site.siteUrl} value={site.siteUrl}>
|
||||
{site.siteUrl}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!selectedSite || selectSite.isPending}
|
||||
onClick={() =>
|
||||
selectSite.mutate({ projectId, siteUrl: selectedSite })
|
||||
}
|
||||
>
|
||||
{selectSite.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Connect property
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Token expired — show reconnect prompt
|
||||
if (connection.lastSyncStatus === 'token_expired') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Connected to Google Search Console.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-destructive/50 bg-destructive/5 p-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">Authorization expired</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your Google Search Console authorization has expired or been
|
||||
revoked. Please reconnect to continue syncing data.
|
||||
</p>
|
||||
{connection.lastSyncError && (
|
||||
<p className="break-words font-mono text-muted-foreground text-xs">
|
||||
{connection.lastSyncError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-fit"
|
||||
disabled={initiateOAuth.isPending}
|
||||
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||
>
|
||||
{initiateOAuth.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Reconnect Google Search Console
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{disconnect.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fully connected
|
||||
const syncStatusIcon =
|
||||
connection.lastSyncStatus === 'success' ? (
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
) : connection.lastSyncStatus === 'error' ? (
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
) : null;
|
||||
|
||||
const syncStatusVariant =
|
||||
connection.lastSyncStatus === 'success'
|
||||
? 'success'
|
||||
: connection.lastSyncStatus === 'error'
|
||||
? 'destructive'
|
||||
: 'secondary';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">Google Search Console</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
Connected to Google Search Console.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y rounded-lg border">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="font-medium text-sm">Property</div>
|
||||
<div className="font-mono text-muted-foreground text-sm">
|
||||
{connection.siteUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connection.backfillStatus && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="font-medium text-sm">Backfill</div>
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={
|
||||
connection.backfillStatus === 'completed'
|
||||
? 'success'
|
||||
: connection.backfillStatus === 'failed'
|
||||
? 'destructive'
|
||||
: connection.backfillStatus === 'running'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{connection.backfillStatus === 'running' && (
|
||||
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{connection.backfillStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.lastSyncedAt && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="font-medium text-sm">Last synced</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection.lastSyncStatus && (
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={syncStatusVariant as any}
|
||||
>
|
||||
{syncStatusIcon}
|
||||
{connection.lastSyncStatus}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.lastSyncError && (
|
||||
<div className="p-4">
|
||||
<div className="font-medium text-destructive text-sm">
|
||||
Last error
|
||||
</div>
|
||||
<div className="mt-1 break-words font-mono text-muted-foreground text-sm">
|
||||
{connection.lastSyncError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{disconnect.isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ function ProjectDashboard() {
|
||||
{ id: 'tracking', label: 'Tracking script' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'imports', label: 'Imports' },
|
||||
{ id: 'gsc', label: 'Google Search' },
|
||||
];
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user