← back to Open Seo

src/client/features/onboarding/SearchConsoleOnboardingStep.tsx

215 lines

import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react";
import { toast } from "sonner";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import {
  SitePicker,
  type GscSiteSelection,
} from "@/client/features/gsc/SitePicker";
import { startGscLink } from "@/client/features/gsc/startGscLink";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import type { ProjectMarket } from "@/client/features/projects/types";
import {
  getGscConnection,
  listGscSites,
  setGscSite,
} from "@/serverFunctions/gsc";
import { getProjects, setProjectMarket } from "@/serverFunctions/projects";

const GRANT_STATUS_KEY = ["gscGrantStatus"];

/**
 * Onboarding step for connecting Google Search Console: link the account-level
 * OAuth grant, then bind a verified property to the user's first project —
 * the same binding the project's Integrations page does — so it's done in one
 * place.
 */
export function SearchConsoleOnboardingStep() {
  const projectsQuery = useQuery({
    queryKey: ["projects"],
    queryFn: () => getProjects(),
  });
  const project = projectsQuery.data?.[0];

  return (
    <div className="space-y-8">
      <div className="space-y-4">
        <h2 className="text-lg font-semibold">
          Connect with Google Search Console now?
        </h2>

        {project ? <GscConnect projectId={project.id} /> : <Checking />}

        <p className="hidden sm:block text-xs leading-relaxed text-base-content/55">
          For now, Search Console data flows through the OpenSEO MCP. We're
          building it into the OpenSEO app soon too.
        </p>
      </div>

      <div className="space-y-4">
        <h2 className="text-lg font-semibold">Choose country &amp; language</h2>
        {project ? <DefaultMarketPicker project={project} /> : <Checking />}
      </div>
    </div>
  );
}

/**
 * Sets the project's default market during onboarding, so keyword, SERP, and
 * domain data lands on the user's market from their first search instead of
 * defaulting to the US. Saves on change — the step's Continue button belongs
 * to the wizard, so a separate Save here would be easy to walk past.
 */
function DefaultMarketPicker({
  project,
}: {
  project: { id: string; locationCode: number; languageCode: string };
}) {
  const queryClient = useQueryClient();
  const [market, setMarket] = React.useState<ProjectMarket>({
    locationCode: project.locationCode,
    languageCode: project.languageCode,
  });

  const saveMutation = useMutation({
    mutationFn: (next: ProjectMarket) =>
      setProjectMarket({ data: { projectId: project.id, ...next } }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] }),
    onError: (error) => toast.error(getStandardErrorMessage(error)),
  });

  const handleChange = (next: ProjectMarket) => {
    setMarket(next);
    saveMutation.mutate(next);
  };

  return (
    <div className="space-y-2">
      <ProjectMarketFields
        value={market}
        onChange={handleChange}
        hideLanguageOnMobile
      />
      <p className="hidden sm:block text-xs leading-relaxed text-base-content/55">
        We'll use this country and language for keyword, SERP, and domain data
        unless you pick a different one. You can change it in project settings.
      </p>
    </div>
  );
}

/** Connect + pick-a-property flow, scoped to a known project. */
function GscConnect({ projectId }: { projectId: string }) {
  const queryClient = useQueryClient();
  const [selection, setSelection] = React.useState<GscSiteSelection | null>(
    null,
  );

  const connectionKey = ["gscConnection", projectId];
  const connectionQuery = useQuery({
    queryKey: connectionKey,
    queryFn: () => getGscConnection({ data: { projectId } }),
  });
  const connection = connectionQuery.data;
  const connected = Boolean(connection?.connected);
  const hasGrant = Boolean(connection?.currentUserHasGrant);
  const needsSetup =
    connectionQuery.isSuccess && !connection?.googleOAuthConfigured;

  const sitesQuery = useQuery({
    queryKey: ["gscSites", projectId],
    queryFn: () => listGscSites({ data: { projectId } }),
    enabled: hasGrant && !connected && !needsSetup,
  });
  const accounts = React.useMemo(
    () => sitesQuery.data?.accounts ?? [],
    [sitesQuery.data?.accounts],
  );
  const requiresReconnect = accounts.some(
    (account) => account.requiresReconnect,
  );

  React.useEffect(() => {
    if (!requiresReconnect) return;

    void queryClient.invalidateQueries({
      queryKey: ["gscConnection", projectId],
    });
    void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
  }, [requiresReconnect, queryClient, projectId]);

  const setSiteMutation = useMutation({
    mutationFn: (selected: GscSiteSelection) =>
      setGscSite({ data: { projectId, ...selected } }),
    onSuccess: () => {
      captureClientEvent("gsc:property_select");
      void queryClient.invalidateQueries({ queryKey: connectionKey });
    },
    onError: (error) => toast.error(getStandardErrorMessage(error)),
  });

  const handleConnect = () => {
    captureClientEvent("onboarding:gsc_connect_clicked");
    void startGscLink(window.location.href);
  };

  if (connectionQuery.isLoading) return <Checking />;

  if (needsSetup) {
    return <SelfHostedSetupWarning />;
  }

  if (connected) {
    return (
      <div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm">
        <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-success/20 text-success">
          <Check className="size-3.5" />
        </span>
        <span className="text-base-content/80">
          Connected to <span className="font-mono">{connection?.siteUrl}</span>.
        </span>
      </div>
    );
  }

  if (hasGrant) {
    return (
      <SitePicker
        loading={sitesQuery.isLoading}
        error={sitesQuery.isError}
        accounts={accounts}
        selection={selection}
        onSelect={setSelection}
        onSave={() => selection && setSiteMutation.mutate(selection)}
        saving={setSiteMutation.isPending}
        onRetry={() => void sitesQuery.refetch()}
        onReconnect={handleConnect}
      />
    );
  }

  return (
    <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>
  );
}

function Checking() {
  return (
    <div className="flex items-center gap-2 text-sm text-base-content/50">
      <span className="loading loading-spinner loading-sm" />
      Checking…
    </div>
  );
}