← back to Open Seo

src/client/features/keywords/page/KeywordResearchPage.tsx

348 lines

import { Link } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo } from "react";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { getErrorCode } from "@/client/lib/error-messages";
import { BILLING_ROUTE } from "@/shared/billing";
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
import {
  useKeywordSearchParams,
  useResolvedKeywordLocation,
} from "@/client/features/keywords/state/keywordControllerInternals";
import type {
  KeywordSearchTabInput,
  SearchTab,
} from "@/client/features/search-tabs/types";
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
import {
  tabInputKey,
  useSearchTabNavigation,
} from "@/client/features/search-tabs/useSearchTabNavigation";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import type { KeywordResearchControllerState } from "./types";

type ControllerProps = Omit<KeywordResearchControllerInput, "onFormSubmit">;
type Props = Omit<
  ControllerProps,
  "locationCode" | "displayedLocationCode" | "setPreferredLocationCode"
> & { locationCode?: number };
type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput };

function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
  return tab.input.type === "keyword";
}

export function KeywordResearchPage(input: Props) {
  const setSearchParams = useKeywordSearchParams();
  const projectId = input.projectId;
  const { locationCode, displayedLocationCode, setPreferredLocationCode } =
    useResolvedKeywordLocation({
      projectId,
      locationCode: input.locationCode,
    });

  const navigateToKeywordInput = useCallback(
    (tabInput: KeywordSearchTabInput | null) => {
      if (!tabInput) {
        setSearchParams({
          q: undefined,
          loc: undefined,
          kLimit: undefined,
          mode: undefined,
          cs: undefined,
        });
        return;
      }

      setSearchParams({
        q: tabInput.keyword,
        loc: tabInput.locationCode,
        kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit,
        mode: tabInput.mode === "auto" ? undefined : tabInput.mode,
        cs: tabInput.clickstream ? true : undefined,
      });
    },
    [setSearchParams],
  );

  const urlInput = useMemo<KeywordSearchTabInput | null>(() => {
    const keywords = parseKeywordInput(input.keywordInput);
    const keyword = keywords[0];
    if (!keyword) return null;
    return {
      type: "keyword",
      keyword,
      locationCode,
      resultLimit: input.resultLimit,
      mode: input.keywordMode,
      clickstream: input.clickstream,
    };
  }, [
    input.clickstream,
    input.keywordInput,
    input.keywordMode,
    locationCode,
    input.resultLimit,
  ]);
  const searchTabs = useSearchTabNavigation({
    storageKey: `keyword:${projectId}`,
    urlInput,
    getLabel: useCallback(
      (tabInput) => (tabInput.type === "keyword" ? tabInput.keyword : ""),
      [],
    ),
    navigateToInput: useCallback(
      (tabInput) => {
        navigateToKeywordInput(tabInput?.type === "keyword" ? tabInput : null);
      },
      [navigateToKeywordInput],
    ),
  });

  const activeTab = useMemo<KeywordSearchTab | null>(() => {
    if (!urlInput) return null;
    const tab = searchTabs.tabs.find(
      (candidate) => candidate.id === searchTabs.activeTabId,
    );
    // activeTabId syncs in an effect, so it trails urlInput by a render; the
    // stale tab must not drive a paid query for a market the URL no longer names.
    return tab &&
      isKeywordSearchTab(tab) &&
      tabInputKey(tab.input) === tabInputKey(urlInput)
      ? tab
      : null;
  }, [searchTabs.activeTabId, searchTabs.tabs, urlInput]);

  const onFormSubmit = useCallback(
    (value: KeywordControlsValues) => {
      const keywords = parseKeywordInput(value.keyword);
      if (keywords.length === 0) return;

      const inputs: KeywordSearchTabInput[] = keywords.map((keyword) => ({
        type: "keyword",
        keyword,
        locationCode: value.locationCode,
        resultLimit: value.resultLimit,
        mode: value.mode,
        clickstream: value.clickstream,
      }));

      let activeInput: KeywordSearchTabInput | null = null;
      for (const tabInput of inputs) {
        const result = searchTabs.openTab(tabInput);
        if (result.tab?.input.type === "keyword") {
          activeInput = result.tab.input;
        }
      }
      if (activeInput) navigateToKeywordInput(activeInput);
    },
    [navigateToKeywordInput, searchTabs],
  );
  const showRecentSearches = useCallback(() => {
    searchTabs.setActiveTab(null);
    navigateToKeywordInput(null);
  }, [navigateToKeywordInput, searchTabs]);
  const getOpenKeywordTabs = useCallback(
    () =>
      searchTabs.tabs.flatMap((tab) =>
        tab.input.type === "keyword"
          ? [
              {
                keyword: tab.input.keyword,
                locationCode: tab.input.locationCode,
                resultLimit: tab.input.resultLimit,
                mode: tab.input.mode,
                clickstream: tab.input.clickstream,
              },
            ]
          : [],
      ),
    [searchTabs.tabs],
  );

  const controllerInput = useMemo<ControllerProps>(
    () =>
      activeTab
        ? {
            ...input,
            keywordInput: activeTab.input.keyword,
            locationCode: activeTab.input.locationCode,
            displayedLocationCode:
              activeTab.input.locationCode ?? displayedLocationCode,
            setPreferredLocationCode,
            resultLimit: activeTab.input.resultLimit,
            keywordMode: activeTab.input.mode,
            clickstream: activeTab.input.clickstream,
            getOpenKeywordTabs,
            keywordTabsLimit: searchTabs.limit,
          }
        : {
            ...input,
            locationCode,
            displayedLocationCode,
            setPreferredLocationCode,
            getOpenKeywordTabs,
            keywordTabsLimit: searchTabs.limit,
          },
    [
      activeTab,
      getOpenKeywordTabs,
      input,
      displayedLocationCode,
      locationCode,
      searchTabs.limit,
      setPreferredLocationCode,
    ],
  );
  const controller = useKeywordResearchController({
    ...controllerInput,
    onFormSubmit,
  });
  useEffect(() => {
    controller.controlsForm.setErrorMap({ onSubmit: undefined });
    controller.controlsForm.setFieldMeta("keyword", (meta) => ({
      ...meta,
      errorMap: {
        ...meta.errorMap,
        onSubmit: undefined,
      },
      errorSourceMap: {
        ...meta.errorSourceMap,
        onSubmit: undefined,
      },
    }));
  }, [controller.controlsForm, searchTabs.tabs]);

  return (
    <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
      <div className="mx-auto flex max-w-7xl flex-col gap-5">
        <div>
          <h1 className="text-2xl font-semibold">Keyword Research</h1>
          <p className="text-sm text-base-content/70">
            Discover keyword ideas, search demand, and ranking opportunities.
          </p>
        </div>

        <KeywordResearchSearchBar controller={controller} />
        {controller.hasSearched ? (
          <div className="flex flex-col gap-2">
            <button
              type="button"
              data-testid="keyword-research-recent-searches"
              className="btn btn-ghost btn-sm w-fit gap-2 px-0 text-base-content/70 hover:bg-transparent"
              onClick={showRecentSearches}
            >
              <ArrowLeft className="size-4" />
              Recent searches
            </button>
            <SearchTabStrip
              projectId={projectId}
              tabs={searchTabs.tabs}
              activeTabId={searchTabs.activeTabId}
              onSelect={searchTabs.selectTab}
              onClose={searchTabs.closeTab}
              onViewed={searchTabs.markTabViewed}
            />
          </div>
        ) : null}
        <KeywordResearchContent
          controller={controller}
          projectId={input.projectId}
        />
        <KeywordSaveDialog controller={controller} />
      </div>
    </div>
  );
}

function KeywordResearchContent({
  controller,
  projectId,
}: {
  controller: KeywordResearchControllerState;
  projectId: string;
}) {
  if (controller.isLoading) {
    return <KeywordResearchLoadingState />;
  }

  if (controller.researchError) {
    const isCreditsError =
      getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS";

    return (
      <div className="flex-1 flex items-center justify-center pt-1">
        <div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3">
          <div className="flex items-start gap-2">
            <AlertCircle className="mt-0.5 size-4 shrink-0" />
            <p className="text-sm">{controller.researchError}</p>
          </div>
          {isCreditsError ? (
            <Link to={BILLING_ROUTE} className="btn btn-sm">
              Go to Billing
            </Link>
          ) : (
            <button className="btn btn-sm" onClick={controller.retrySearch}>
              Try again
            </button>
          )}
        </div>
      </div>
    );
  }

  if (controller.rows.length === 0) {
    return (
      <KeywordResearchEmptyState
        controller={controller}
        projectId={projectId}
      />
    );
  }

  return <KeywordResearchResults controller={controller} />;
}

function KeywordSaveDialog({
  controller,
}: {
  controller: KeywordResearchControllerState;
}) {
  if (!controller.showSaveDialog) return null;

  return (
    <div className="modal modal-open">
      <div className="modal-box">
        <h3 className="font-bold text-lg">
          Save {controller.selectedRows.size} Keywords
        </h3>
        <div className="py-4">
          <p className="text-base-content/70 text-sm">
            These keywords will be saved to your current project.
          </p>
        </div>
        <div className="modal-action">
          <button
            className="btn"
            onClick={() => controller.setShowSaveDialog(false)}
          >
            Cancel
          </button>
          <button className="btn btn-primary" onClick={controller.confirmSave}>
            Save
          </button>
        </div>
      </div>
      <div
        className="modal-backdrop"
        onClick={() => controller.setShowSaveDialog(false)}
      />
    </div>
  );
}