This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 13:59:03 +01:00
parent 70ca44f039
commit 2981638893
21 changed files with 1609 additions and 1 deletions

View File

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