metatron-open-seo/src/client/features/gsc/SearchConsoleConnectionCard.tsx
2026-06-04 13:50:26 -04:00

254 lines
8.2 KiB
TypeScript

import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { SitePicker } from "@/client/features/gsc/SitePicker";
import { startGscLink } from "@/client/features/gsc/startGscLink";
import {
disconnectGsc,
getGscConnection,
listGscSites,
setGscSite,
} from "@/serverFunctions/gsc";
const GRANT_STATUS_KEY = ["gscGrantStatus"];
export function SearchConsoleConnectionCard({
projectId,
}: {
projectId: string;
}) {
const hosted = isHostedClientAuthMode();
const queryClient = useQueryClient();
const [picking, setPicking] = React.useState(false);
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState<string>("");
const connectionKey = ["gscConnection", projectId];
const connectionQuery = useQuery({
queryKey: connectionKey,
queryFn: () => getGscConnection({ data: { projectId } }),
enabled: hosted,
});
const connection = connectionQuery.data;
const connected = Boolean(connection?.connected);
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
const sitesQuery = useQuery({
queryKey: ["gscSites", projectId],
queryFn: () => listGscSites({ data: { projectId } }),
enabled: Boolean(showPicker),
});
const setSiteMutation = useMutation({
mutationFn: (siteUrl: string) =>
setGscSite({ data: { projectId, siteUrl } }),
onSuccess: () => {
captureClientEvent("gsc:property_select");
toast.success("Search Console connected");
setPicking(false);
void queryClient.invalidateQueries({ queryKey: connectionKey });
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
},
onError: (error) => toast.error(getStandardErrorMessage(error)),
});
const disconnectMutation = useMutation({
mutationFn: () => disconnectGsc({ data: { projectId } }),
onSuccess: () => {
toast.success("Search Console disconnected");
setPicking(false);
void queryClient.invalidateQueries({ queryKey: connectionKey });
// Disconnect can drop the account-level grant server-side; keep the
// shared grant-status cache (onboarding step + re-engagement nudge) honest.
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
},
onError: (error) => toast.error(getStandardErrorMessage(error)),
});
const handleConnect = () => void startGscLink(window.location.href);
if (!hosted) {
return (
<IntegrationCard>
<p className="text-sm text-base-content/60">
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
</p>
</IntegrationCard>
);
}
return (
<IntegrationCard
status={
connectionQuery.isLoading
? undefined
: connected
? "connected"
: "disconnected"
}
>
{connectionQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-base-content/50">
<span className="loading loading-spinner loading-sm" />
Checking
</div>
) : connected && !picking ? (
<ConnectedState
siteUrl={connection?.siteUrl ?? ""}
connectedByEmail={connection?.connectedByEmail ?? null}
onChange={() => {
setSelectedSiteUrl(connection?.siteUrl ?? "");
setPicking(true);
}}
onDisconnect={() => disconnectMutation.mutate()}
disconnecting={disconnectMutation.isPending}
/>
) : showPicker ? (
<SitePicker
loading={sitesQuery.isLoading}
error={sitesQuery.isError}
sites={sitesQuery.data?.sites ?? []}
selectedSiteUrl={selectedSiteUrl}
onSelect={setSelectedSiteUrl}
onSave={() =>
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
}
saving={setSiteMutation.isPending}
onReconnect={handleConnect}
secondaryAction={
connected
? { label: "Cancel", onClick: () => setPicking(false) }
: {
label: "Disconnect",
destructive: true,
disabled: disconnectMutation.isPending,
onClick: () => disconnectMutation.mutate(),
}
}
/>
) : (
<div className="space-y-4">
<p className="text-sm text-base-content/70">
Real clicks, impressions, and rankings. Free.
</p>
<button
type="button"
onClick={handleConnect}
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<GoogleGlyph className="size-[18px]" />
Connect with Google
</button>
</div>
)}
</IntegrationCard>
);
}
// ---------------------------------------------------------------------------
// Card shell
// ---------------------------------------------------------------------------
function IntegrationCard({
status,
children,
}: {
status?: "connected" | "disconnected";
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
<div className="flex items-start justify-between gap-4 p-5 sm:p-6">
<div>
<h2 className="text-base font-semibold leading-tight">
Google Search Console
</h2>
<p className="mt-0.5 text-sm text-base-content/55">
Your search data, straight from Google.
</p>
</div>
{status ? <StatusPill status={status} /> : null}
</div>
<div className="border-t border-base-300 p-5 sm:p-6">{children}</div>
</div>
);
}
function StatusPill({ status }: { status: "connected" | "disconnected" }) {
const connected = status === "connected";
return (
<span
className={[
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
connected
? "border-success/30 bg-success/10 text-success"
: "border-base-300 bg-base-200 text-base-content/60",
].join(" ")}
>
<span
className={[
"size-1.5 rounded-full",
connected ? "bg-success" : "bg-base-content/40",
].join(" ")}
/>
{connected ? "Connected" : "Not connected"}
</span>
);
}
// ---------------------------------------------------------------------------
// Connected state
// ---------------------------------------------------------------------------
function ConnectedState({
siteUrl,
connectedByEmail,
onChange,
onDisconnect,
disconnecting,
}: {
siteUrl: string;
connectedByEmail: string | null;
onChange: () => void;
onDisconnect: () => void;
disconnecting: boolean;
}) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-base-300 bg-base-200/40 p-3.5">
<div className="grid size-9 shrink-0 place-items-center rounded-md border border-base-300 bg-base-100">
<GoogleGlyph className="size-[18px]" />
</div>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{siteUrl}</p>
{connectedByEmail ? (
<p className="truncate text-xs text-base-content/55">
Connected by {connectedByEmail}
</p>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onChange}
>
Change property
</button>
<button
type="button"
className="btn btn-ghost btn-sm text-error hover:bg-error/10"
onClick={onDisconnect}
disabled={disconnecting}
>
Disconnect
</button>
</div>
</div>
);
}