wip
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
LayoutPanelTopIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
TrendingUpDownIcon,
|
||||
UndoDotIcon,
|
||||
@@ -59,6 +60,7 @@ export default function SidebarProjectMenu({
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
|
||||
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
|
||||
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||
Manage
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
|
||||
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
||||
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
||||
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
|
||||
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
|
||||
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
||||
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
|
||||
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
|
||||
path: '/sessions',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSeoRoute =
|
||||
AppOrganizationIdProjectIdSeoRouteImport.update({
|
||||
id: '/seo',
|
||||
path: '/seo',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdReportsRoute =
|
||||
AppOrganizationIdProjectIdReportsRouteImport.update({
|
||||
id: '/reports',
|
||||
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
||||
path: '/imports',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
|
||||
id: '/gsc',
|
||||
path: '/gsc',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
|
||||
id: '/events',
|
||||
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -747,6 +765,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||
@@ -789,6 +808,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
| '/$organizationId/$projectId/references'
|
||||
| '/$organizationId/$projectId/reports'
|
||||
| '/$organizationId/$projectId/seo'
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/clients'
|
||||
| '/$organizationId/$projectId/settings/details'
|
||||
| '/$organizationId/$projectId/settings/events'
|
||||
| '/$organizationId/$projectId/settings/gsc'
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/tracking'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
| '/$organizationId/$projectId/references'
|
||||
| '/$organizationId/$projectId/reports'
|
||||
| '/$organizationId/$projectId/seo'
|
||||
| '/$organizationId/$projectId/sessions'
|
||||
| '/$organizationId/integrations'
|
||||
| '/$organizationId/members'
|
||||
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/clients'
|
||||
| '/$organizationId/$projectId/settings/details'
|
||||
| '/$organizationId/$projectId/settings/events'
|
||||
| '/$organizationId/$projectId/settings/gsc'
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/tracking'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/realtime'
|
||||
| '/_app/$organizationId/$projectId/references'
|
||||
| '/_app/$organizationId/$projectId/reports'
|
||||
| '/_app/$organizationId/$projectId/seo'
|
||||
| '/_app/$organizationId/$projectId/sessions'
|
||||
| '/_app/$organizationId/integrations'
|
||||
| '/_app/$organizationId/integrations/_tabs'
|
||||
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/seo': {
|
||||
id: '/_app/$organizationId/$projectId/seo'
|
||||
path: '/seo'
|
||||
fullPath: '/$organizationId/$projectId/seo'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/reports': {
|
||||
id: '/_app/$organizationId/$projectId/reports'
|
||||
path: '/reports'
|
||||
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
|
||||
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||
path: '/gsc'
|
||||
fullPath: '/$organizationId/$projectId/settings/gsc'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/events': {
|
||||
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||
path: '/events'
|
||||
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
|
||||
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
|
||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsGscRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
|
||||
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
||||
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
|
||||
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
|
||||
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
|
||||
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
||||
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdReferencesRoute,
|
||||
AppOrganizationIdProjectIdReportsRoute:
|
||||
AppOrganizationIdProjectIdReportsRoute,
|
||||
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
|
||||
AppOrganizationIdProjectIdSessionsRoute:
|
||||
AppOrganizationIdProjectIdSessionsRoute,
|
||||
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,
|
||||
|
||||
289
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
289
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { subDays, format } from 'date-fns';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/seo'
|
||||
)({
|
||||
component: SeoPage,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('SEO') }],
|
||||
}),
|
||||
});
|
||||
|
||||
const startDate = format(subDays(new Date(), 30), 'yyyy-MM-dd');
|
||||
const endDate = format(subDays(new Date(), 1), 'yyyy-MM-dd');
|
||||
|
||||
function SeoPage() {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const connectionQuery = useQuery(
|
||||
trpc.gsc.getConnection.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const isConnected = connection && connection.siteUrl;
|
||||
|
||||
const overviewQuery = useQuery(
|
||||
trpc.gsc.getOverview.queryOptions(
|
||||
{ projectId, startDate, endDate },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const pagesQuery = useQuery(
|
||||
trpc.gsc.getPages.queryOptions(
|
||||
{ projectId, startDate, endDate, limit: 50 },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
const queriesQuery = useQuery(
|
||||
trpc.gsc.getQueries.queryOptions(
|
||||
{ projectId, startDate, endDate, limit: 50 },
|
||||
{ enabled: !!isConnected }
|
||||
)
|
||||
);
|
||||
|
||||
if (connectionQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="SEO" description="Google Search Console data" />
|
||||
<div className="space-y-4 mt-8">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="SEO" description="Connect Google Search Console to see your search performance data." />
|
||||
<div className="mt-16 flex flex-col items-center gap-4 text-center">
|
||||
<div className="rounded-full bg-muted p-6">
|
||||
<svg
|
||||
className="h-12 w-12 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">No SEO data yet</h2>
|
||||
<p className="text-muted-foreground max-w-sm">
|
||||
Connect Google Search Console to track your search impressions, clicks, and keyword rankings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/settings/gsc',
|
||||
params: { organizationId, projectId },
|
||||
})
|
||||
}
|
||||
>
|
||||
Connect Google Search Console
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data ?? [];
|
||||
const pages = pagesQuery.data ?? [];
|
||||
const queries = queriesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="SEO"
|
||||
description={`Search performance for ${connection.siteUrl}`}
|
||||
/>
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
{/* Summary metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{(['clicks', 'impressions', 'ctr', 'position'] as const).map((metric) => {
|
||||
const total = overview.reduce((sum, row) => {
|
||||
if (metric === 'ctr' || metric === 'position') {
|
||||
return sum + row[metric];
|
||||
}
|
||||
return sum + row[metric];
|
||||
}, 0);
|
||||
const display =
|
||||
metric === 'ctr'
|
||||
? `${((total / Math.max(overview.length, 1)) * 100).toFixed(1)}%`
|
||||
: metric === 'position'
|
||||
? (total / Math.max(overview.length, 1)).toFixed(1)
|
||||
: total.toLocaleString();
|
||||
const label =
|
||||
metric === 'ctr'
|
||||
? 'Avg CTR'
|
||||
: metric === 'position'
|
||||
? 'Avg Position'
|
||||
: metric.charAt(0).toUpperCase() + metric.slice(1);
|
||||
|
||||
return (
|
||||
<div key={metric} className="rounded-lg border p-4">
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{overviewQuery.isLoading ? <Skeleton className="h-8 w-24" /> : display}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Clicks over time chart */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-medium mb-4">Clicks over time</h3>
|
||||
{overviewQuery.isLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={overview}>
|
||||
<defs>
|
||||
<linearGradient id="colorClicks" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="clicks"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="url(#colorClicks)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pages and Queries tables */}
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<GscTable
|
||||
title="Top pages"
|
||||
rows={pages}
|
||||
keyLabel="Page"
|
||||
keyField="page"
|
||||
isLoading={pagesQuery.isLoading}
|
||||
/>
|
||||
<GscTable
|
||||
title="Top queries"
|
||||
rows={queries}
|
||||
keyLabel="Query"
|
||||
keyField="query"
|
||||
isLoading={queriesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface GscTableRow {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
function GscTable({
|
||||
title,
|
||||
rows,
|
||||
keyLabel,
|
||||
keyField,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
rows: GscTableRow[];
|
||||
keyLabel: string;
|
||||
keyField: string;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{keyLabel}</TableHead>
|
||||
<TableHead className="text-right">Clicks</TableHead>
|
||||
<TableHead className="text-right">Impressions</TableHead>
|
||||
<TableHead className="text-right">CTR</TableHead>
|
||||
<TableHead className="text-right">Position</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading &&
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
No data yet
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<TableRow key={String(row[keyField])}>
|
||||
<TableCell className="max-w-[200px] truncate font-mono text-xs">
|
||||
{String(row[keyField])}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{row.clicks.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{row.impressions.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{(row.ctr * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right">{row.position.toFixed(1)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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';
|
||||
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';
|
||||
|
||||
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) => {
|
||||
// Route through the API /gsc/initiate endpoint which sets cookies then redirects to Google
|
||||
const apiUrl = (import.meta.env.VITE_API_URL as string) ?? '';
|
||||
const initiateUrl = new URL(`${apiUrl}/gsc/initiate`);
|
||||
initiateUrl.searchParams.set('state', data.state);
|
||||
initiateUrl.searchParams.set('code_verifier', data.codeVerifier);
|
||||
initiateUrl.searchParams.set('project_id', data.projectId);
|
||||
initiateUrl.searchParams.set('redirect', data.url);
|
||||
window.location.href = initiateUrl.toString();
|
||||
},
|
||||
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="text-lg font-medium">Google Search Console</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Connect your Google Search Console property to import search performance data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-6 flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will be redirected to Google to authorize access. Only read-only access to Search Console data is requested.
|
||||
</p>
|
||||
<Button
|
||||
className="w-fit"
|
||||
onClick={() => initiateOAuth.mutate({ projectId })}
|
||||
disabled={initiateOAuth.isPending}
|
||||
>
|
||||
{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="text-lg font-medium">Select a property</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Choose which Google Search Console property to connect to this project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-6 space-y-4">
|
||||
{sitesQuery.isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : sites.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No Search Console properties found for this Google account.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedSite} onValueChange={setSelectedSite}>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
>
|
||||
Cancel
|
||||
</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="text-lg font-medium">Google Search Console</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Connected to Google Search Console.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border divide-y">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Property</div>
|
||||
<div className="font-mono text-sm text-muted-foreground">
|
||||
{connection.siteUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connection.backfillStatus && (
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">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="p-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">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-sm text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.lastSyncError && (
|
||||
<div className="p-4">
|
||||
<div className="text-sm font-medium text-destructive">Last error</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground font-mono break-words">
|
||||
{connection.lastSyncError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => disconnect.mutate({ projectId })}
|
||||
disabled={disconnect.isPending}
|
||||
>
|
||||
{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