← back to Open Seo

src/client/features/gsc/GscReEngagementModal.tsx

130 lines

import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Modal } from "@/client/components/Modal";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { startGscLink } from "@/client/features/gsc/startGscLink";
import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel";
import { captureClientEvent } from "@/client/lib/posthog";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getGscGrantStatus } from "@/serverFunctions/gsc";
import { dismissGscNudge } from "@/serverFunctions/onboarding";

/**
 * One-time re-engagement prompt nudging users who finished onboarding *before*
 * the Search Console step existed to connect GSC. Hosted-only because this is
 * a hosted onboarding re-engagement nudge. Shows once — server-persisted
 * dismissal means it never reappears after the user connects or dismisses, on
 * any device.
 *
 * `suppressed` lets the layout hide this when another modal (e.g. the missing
 * DataForSEO key prompt) is already showing so the two never stack.
 */
export function GscReEngagementModal({
  projectId,
  suppressed,
}: {
  projectId: string | null;
  suppressed: boolean;
}) {
  const hosted = isHostedClientAuthMode();
  const queryClient = useQueryClient();
  const [closed, setClosed] = React.useState(false);
  const shownRef = React.useRef(false);

  const onboardingQuery = useQuery({
    ...onboardingAnswersQueryOptions(),
    enabled: hosted,
  });
  const grantQuery = useQuery({
    queryKey: ["gscGrantStatus"],
    queryFn: () => getGscGrantStatus(),
    enabled: hosted,
  });

  const dismissMutation = useMutation({
    mutationFn: () => dismissGscNudge(),
    onSettled: () => {
      void queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
    },
  });

  // Legacy users — those who finished onboarding before it included the Search
  // Console step — have no gscNudgeDismissedAt set, so they're the only ones who
  // see this. Anyone who completes current onboarding gets it stamped (they
  // already saw that step), and dismissing/connecting clears it too.
  const eligible =
    hosted &&
    !suppressed &&
    !closed &&
    onboardingQuery.isSuccess &&
    grantQuery.isSuccess &&
    Boolean(onboardingQuery.data?.completedAt) &&
    !onboardingQuery.data?.gscNudgeDismissedAt &&
    !grantQuery.data?.connected;

  React.useEffect(() => {
    if (eligible && !shownRef.current) {
      shownRef.current = true;
      captureClientEvent("gsc:nudge_shown");
    }
  }, [eligible]);

  if (!eligible) return null;

  function persistDismiss() {
    setClosed(true);
    dismissMutation.mutate();
  }

  function handleDismiss() {
    captureClientEvent("gsc:nudge_dismissed");
    persistDismiss();
  }

  function handleConnect() {
    captureClientEvent("gsc:nudge_connect_clicked");
    // Resolve the nudge up front: the user is leaving for Google's consent
    // screen, and on return they'll either have a grant (which suppresses this
    // anyway) or have abandoned it — neither case should re-nag.
    persistDismiss();
    // Land them on the project's settings page so they can pick a property
    // right after granting access (the grant alone has no property bound yet).
    const callbackURL = projectId
      ? `${window.location.origin}/p/${projectId}/settings#search-console`
      : window.location.href;
    void startGscLink(callbackURL);
  }

  return (
    <Modal
      maxWidth="max-w-lg"
      onClose={handleDismiss}
      labelledBy="gsc-nudge-title"
    >
      <div className="space-y-1">
        <h2 id="gsc-nudge-title" className="text-lg font-semibold">
          New: Connect Google Search Console
        </h2>
        <p className="text-sm text-base-content/70">
          Bring your real clicks, impressions, and rankings into OpenSEO and
          query them from Claude or Codex over MCP. It never uses credits.
        </p>
      </div>

      <div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
        <button type="button" className="btn btn-ghost" onClick={handleDismiss}>
          Maybe later
        </button>
        <button
          type="button"
          onClick={handleConnect}
          className="inline-flex items-center justify-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>
    </Modal>
  );
}