fix: use Tanstack Query for Keyword Research (#150)
* keywords: use query cache for research requests * keywords: simplify research request dedupe
This commit is contained in:
parent
2fc568f73c
commit
db1a1d723f
@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
|
||||
@ -9,7 +10,6 @@ import type {
|
||||
KeywordSource,
|
||||
ResultLimit,
|
||||
} from "@/client/features/keywords/keywordResearchTypes";
|
||||
import type { KeywordResearchRow } from "@/types/keywords";
|
||||
|
||||
type AddSearchFn = (
|
||||
keyword: string,
|
||||
@ -17,130 +17,153 @@ type AddSearchFn = (
|
||||
locationName: string,
|
||||
) => void;
|
||||
|
||||
export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
const [rows, setRows] = useState<KeywordResearchRow[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [lastSearchError, setLastSearchError] = useState(false);
|
||||
const [lastResultSource, setLastResultSource] =
|
||||
useState<KeywordSource>("related");
|
||||
const [lastUsedFallback, setLastUsedFallback] = useState(false);
|
||||
const [lastSearchKeyword, setLastSearchKeyword] = useState("");
|
||||
const [lastSearchLocationCode, setLastSearchLocationCode] = useState(
|
||||
DEFAULT_LOCATION_CODE,
|
||||
);
|
||||
const [researchError, setResearchError] = useState<string | null>(null);
|
||||
const [researchMutationError, setResearchMutationError] =
|
||||
useState<unknown>(null);
|
||||
const [searchedKeyword, setSearchedKeyword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Sequence token so a stale fetch (e.g. user fired a second search before
|
||||
// the first resolved) can't overwrite state that belongs to a newer one.
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
const beginSearch = (seedKeyword: string, locationCode: number) => {
|
||||
setResearchError(null);
|
||||
setResearchMutationError(null);
|
||||
setHasSearched(true);
|
||||
setLastSearchError(false);
|
||||
setSearchedKeyword(seedKeyword);
|
||||
setLastSearchKeyword(seedKeyword);
|
||||
setLastSearchLocationCode(locationCode);
|
||||
setIsLoading(true);
|
||||
};
|
||||
|
||||
const resetResearch = () => {
|
||||
setRows([]);
|
||||
setHasSearched(false);
|
||||
setLastSearchError(false);
|
||||
setLastResultSource("related");
|
||||
setLastUsedFallback(false);
|
||||
setLastSearchKeyword("");
|
||||
setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
|
||||
setResearchError(null);
|
||||
setResearchMutationError(null);
|
||||
setSearchedKeyword("");
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const runSearch = async (
|
||||
input: {
|
||||
type KeywordResearchQueryInput = {
|
||||
projectId: string;
|
||||
keywords: string[];
|
||||
keywordInput: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
},
|
||||
handlers?: {
|
||||
onSuccess?: (seedKeyword: string, rows: KeywordResearchRow[]) => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
) => {
|
||||
const seedKeyword = input.keywords[0] ?? "";
|
||||
const languageCode = getLanguageCode(input.locationCode);
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
const isStale = () => requestSeqRef.current !== requestSeq;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await researchKeywords({
|
||||
data: {
|
||||
keywords: input.keywords,
|
||||
type KeywordResearchRequest = {
|
||||
projectId: string;
|
||||
keywords: string[];
|
||||
seedKeyword: string;
|
||||
locationCode: number;
|
||||
languageCode: string;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
};
|
||||
|
||||
const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseSearchKeywords(value: string) {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildKeywordResearchQueryKey(request: KeywordResearchRequest | null) {
|
||||
return request
|
||||
? [
|
||||
"keywordResearch",
|
||||
request.projectId,
|
||||
request.keywords,
|
||||
request.locationCode,
|
||||
request.languageCode,
|
||||
request.resultLimit,
|
||||
request.mode,
|
||||
]
|
||||
: ["keywordResearch", "idle"];
|
||||
}
|
||||
|
||||
export function useKeywordResearchData(
|
||||
input: KeywordResearchQueryInput,
|
||||
addSearch: AddSearchFn,
|
||||
) {
|
||||
const keywords = useMemo(
|
||||
() => parseSearchKeywords(input.keywordInput),
|
||||
[input.keywordInput],
|
||||
);
|
||||
const request = useMemo<KeywordResearchRequest | null>(() => {
|
||||
const seedKeyword = keywords[0] ?? "";
|
||||
if (!seedKeyword) return null;
|
||||
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
keywords,
|
||||
seedKeyword,
|
||||
locationCode: input.locationCode,
|
||||
languageCode,
|
||||
languageCode: getLanguageCode(input.locationCode),
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
};
|
||||
}, [
|
||||
input.locationCode,
|
||||
input.mode,
|
||||
input.projectId,
|
||||
input.resultLimit,
|
||||
keywords,
|
||||
]);
|
||||
const queryKey = useMemo(
|
||||
() => buildKeywordResearchQueryKey(request),
|
||||
[request],
|
||||
);
|
||||
const queryKeyString = JSON.stringify(queryKey);
|
||||
|
||||
const researchQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => {
|
||||
if (!request) {
|
||||
throw new Error("Keyword research query ran without request params");
|
||||
}
|
||||
|
||||
return researchKeywords({
|
||||
data: {
|
||||
projectId: request.projectId,
|
||||
keywords: request.keywords,
|
||||
locationCode: request.locationCode,
|
||||
languageCode: request.languageCode,
|
||||
resultLimit: request.resultLimit,
|
||||
mode: request.mode,
|
||||
},
|
||||
});
|
||||
|
||||
if (isStale()) return;
|
||||
|
||||
setResearchError(null);
|
||||
setResearchMutationError(null);
|
||||
setRows(result.rows);
|
||||
setLastResultSource(result.source);
|
||||
setLastUsedFallback(result.usedFallback);
|
||||
|
||||
captureClientEvent("keyword_research:search_complete", {
|
||||
location_code: input.locationCode,
|
||||
search_mode: input.mode,
|
||||
result_count: result.rows.length,
|
||||
},
|
||||
enabled: request !== null,
|
||||
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
retry: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (seedKeyword) {
|
||||
addSearch(
|
||||
seedKeyword,
|
||||
input.locationCode,
|
||||
LOCATIONS[input.locationCode] || "Unknown",
|
||||
);
|
||||
}
|
||||
const handledSuccessKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!request || !researchQuery.isSuccess || !researchQuery.data) return;
|
||||
if (handledSuccessKeyRef.current === queryKeyString) return;
|
||||
handledSuccessKeyRef.current = queryKeyString;
|
||||
|
||||
handlers?.onSuccess?.(seedKeyword, result.rows);
|
||||
} catch (error) {
|
||||
if (isStale()) return;
|
||||
setLastSearchError(true);
|
||||
setRows([]);
|
||||
setResearchMutationError(error);
|
||||
setResearchError(getStandardErrorMessage(error, "Research failed."));
|
||||
handlers?.onError?.();
|
||||
} finally {
|
||||
if (!isStale()) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
captureClientEvent("keyword_research:search_complete", {
|
||||
location_code: request.locationCode,
|
||||
search_mode: request.mode,
|
||||
result_count: researchQuery.data.rows.length,
|
||||
});
|
||||
|
||||
addSearch(
|
||||
request.seedKeyword,
|
||||
request.locationCode,
|
||||
LOCATIONS[request.locationCode] || "Unknown",
|
||||
);
|
||||
}, [
|
||||
addSearch,
|
||||
queryKeyString,
|
||||
request,
|
||||
researchQuery.data,
|
||||
researchQuery.isSuccess,
|
||||
]);
|
||||
|
||||
const hasSearched = request !== null;
|
||||
const rows = hasSearched ? (researchQuery.data?.rows ?? []) : [];
|
||||
const researchError =
|
||||
hasSearched && researchQuery.isError
|
||||
? getStandardErrorMessage(researchQuery.error, "Research failed.")
|
||||
: null;
|
||||
|
||||
return {
|
||||
rows,
|
||||
hasSearched,
|
||||
lastSearchError,
|
||||
lastResultSource,
|
||||
lastUsedFallback,
|
||||
lastSearchKeyword,
|
||||
lastSearchLocationCode,
|
||||
lastSearchError: hasSearched && researchQuery.isError,
|
||||
lastResultSource:
|
||||
researchQuery.data?.source ?? ("related" as KeywordSource),
|
||||
lastUsedFallback: researchQuery.data?.usedFallback ?? false,
|
||||
lastSearchKeyword: request?.seedKeyword ?? "",
|
||||
lastSearchLocationCode: request?.locationCode ?? DEFAULT_LOCATION_CODE,
|
||||
researchError,
|
||||
researchMutationError,
|
||||
searchedKeyword,
|
||||
isLoading,
|
||||
beginSearch,
|
||||
resetResearch,
|
||||
runSearch,
|
||||
researchMutationError: researchQuery.error,
|
||||
searchedKeyword: request?.seedKeyword ?? "",
|
||||
isLoading: hasSearched && researchQuery.isPending,
|
||||
researchQuery,
|
||||
retryResearch: researchQuery.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@ -81,10 +81,7 @@ function KeywordResearchContent({
|
||||
Go to Billing
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => controller.onSearch()}
|
||||
>
|
||||
<button className="btn btn-sm" onClick={controller.retrySearch}>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -17,7 +17,6 @@ import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||
import {
|
||||
buildKeywordSearchKey,
|
||||
getNextSortParams,
|
||||
parseKeywordInput,
|
||||
useSaveAndExportActions,
|
||||
} from "./keywordControllerActions";
|
||||
import {
|
||||
@ -45,6 +44,7 @@ export function useKeywordResearchController(
|
||||
const state = useKeywordControllerState(input);
|
||||
const controlsForm = state.controlsForm;
|
||||
const setSearchParams = state.setSearchParams;
|
||||
const retryResearch = state.retryResearch;
|
||||
|
||||
const onSearch = useCallback(
|
||||
(overrides?: Partial<{ keyword: string; locationCode: number }>) => {
|
||||
@ -61,6 +61,10 @@ export function useKeywordResearchController(
|
||||
[controlsForm],
|
||||
);
|
||||
|
||||
const retrySearch = useCallback(() => {
|
||||
void retryResearch();
|
||||
}, [retryResearch]);
|
||||
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
@ -124,6 +128,7 @@ export function useKeywordResearchController(
|
||||
removeHistoryItem: state.removeHistoryItem,
|
||||
researchError: state.researchError,
|
||||
researchMutationError: state.researchMutationError,
|
||||
retrySearch,
|
||||
resetFilters: state.resetFilters,
|
||||
rows: state.rows,
|
||||
searchedKeyword: state.searchedKeyword,
|
||||
@ -191,66 +196,41 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
lastSearchLocationCode,
|
||||
researchError,
|
||||
researchMutationError,
|
||||
researchQuery,
|
||||
searchedKeyword,
|
||||
isLoading,
|
||||
beginSearch,
|
||||
resetResearch,
|
||||
runSearch,
|
||||
} = useKeywordResearchData(addSearch);
|
||||
retryResearch,
|
||||
} = useKeywordResearchData(
|
||||
{
|
||||
projectId: input.projectId,
|
||||
keywordInput: input.keywordInput,
|
||||
locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
},
|
||||
addSearch,
|
||||
);
|
||||
const setSearchParams = useKeywordSearchParams();
|
||||
const saveMutation = useKeywordSaveMutation(input.projectId);
|
||||
|
||||
// Tracks the parameters used for the most recent search trigger so the
|
||||
// URL-driven effect below doesn't re-fire after the form-submit path
|
||||
// already kicked off a search for the same params.
|
||||
const lastTriggerKeyRef = useRef<string | null>(null);
|
||||
const activeSearchKey = input.keywordInput.trim()
|
||||
? buildKeywordSearchKey({
|
||||
keyword: input.keywordInput,
|
||||
locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
})
|
||||
: null;
|
||||
|
||||
const triggerSearch = useCallback(
|
||||
(params: {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
}) => {
|
||||
const keywords = parseKeywordInput(params.keyword);
|
||||
if (keywords.length === 0) return;
|
||||
const previousSearchKeyRef = useRef<string | null>(null);
|
||||
const handledSerpSearchKeyRef = useRef<string | null>(null);
|
||||
|
||||
lastTriggerKeyRef.current = buildKeywordSearchKey(params);
|
||||
uiState.setSelectedKeyword(null);
|
||||
const clearActiveKeywordResult = useCallback(() => {
|
||||
clearSelection();
|
||||
uiState.setSelectedKeyword(null);
|
||||
setSerpKeyword(null);
|
||||
beginSearch(keywords[0] ?? "", params.locationCode);
|
||||
|
||||
void runSearch(
|
||||
{
|
||||
projectId: input.projectId,
|
||||
keywords,
|
||||
locationCode: params.locationCode,
|
||||
resultLimit: params.resultLimit,
|
||||
mode: params.mode,
|
||||
},
|
||||
{
|
||||
onSuccess: (seedKeyword, nextRows) => {
|
||||
if (nextRows.length === 0) {
|
||||
setSerpKeyword(null);
|
||||
return;
|
||||
}
|
||||
setSerpKeyword(seedKeyword);
|
||||
setSerpPage(0);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
beginSearch,
|
||||
clearSelection,
|
||||
input.projectId,
|
||||
runSearch,
|
||||
setSerpKeyword,
|
||||
setSerpPage,
|
||||
uiState,
|
||||
],
|
||||
);
|
||||
}, [clearSelection, setSerpKeyword, setSerpPage, uiState]);
|
||||
|
||||
const controlsForm = useKeywordControlsForm(
|
||||
{
|
||||
@ -269,63 +249,39 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
kLimit: value.resultLimit === 150 ? undefined : value.resultLimit,
|
||||
mode: value.mode === "auto" ? undefined : value.mode,
|
||||
});
|
||||
|
||||
// Trigger immediately so re-submitting the same query (URL unchanged)
|
||||
// still refetches. The dedup ref prevents the URL effect below from
|
||||
// double-firing in the typical (URL-changes) case.
|
||||
triggerSearch({
|
||||
keyword: value.keyword,
|
||||
locationCode: value.locationCode,
|
||||
resultLimit: value.resultLimit,
|
||||
mode: value.mode,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// URL-driven search trigger. Fires when the user lands on a shareable URL
|
||||
// (direct link, cmd+click on a history item, browser back/forward) so the
|
||||
// page reproduces the search those params describe without a form submit.
|
||||
// When the URL is cleared (no `q`), the page resets to the recent-searches
|
||||
// empty state so the "Recent searches" Link works without an extra handler.
|
||||
// The URL is the source of truth for paid keyword research queries. This
|
||||
// effect only resets UI state around a new query key; TanStack Query owns the
|
||||
// actual fetch, cache, dedupe, and error lifecycle.
|
||||
useEffect(() => {
|
||||
const trimmed = input.keywordInput.trim();
|
||||
if (activeSearchKey === previousSearchKeyRef.current) return;
|
||||
previousSearchKeyRef.current = activeSearchKey;
|
||||
handledSerpSearchKeyRef.current = null;
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
if (lastTriggerKeyRef.current === null) return;
|
||||
lastTriggerKeyRef.current = null;
|
||||
resetResearch();
|
||||
clearSelection();
|
||||
uiState.setSelectedKeyword(null);
|
||||
setSerpKeyword(null);
|
||||
setSerpPage(0);
|
||||
if (!activeSearchKey) {
|
||||
clearActiveKeywordResult();
|
||||
return;
|
||||
}
|
||||
|
||||
const urlKey = buildKeywordSearchKey({
|
||||
keyword: input.keywordInput,
|
||||
locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
});
|
||||
if (urlKey === lastTriggerKeyRef.current) return;
|
||||
clearActiveKeywordResult();
|
||||
}, [activeSearchKey, clearActiveKeywordResult]);
|
||||
|
||||
triggerSearch({
|
||||
keyword: input.keywordInput,
|
||||
locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.keywordMode,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeSearchKey || !researchQuery.isSuccess) return;
|
||||
if (handledSerpSearchKeyRef.current === activeSearchKey) return;
|
||||
|
||||
handledSerpSearchKeyRef.current = activeSearchKey;
|
||||
setSerpKeyword(rows.length > 0 ? searchedKeyword : null);
|
||||
setSerpPage(0);
|
||||
}, [
|
||||
clearSelection,
|
||||
input.keywordInput,
|
||||
input.keywordMode,
|
||||
input.resultLimit,
|
||||
locationCode,
|
||||
resetResearch,
|
||||
activeSearchKey,
|
||||
researchQuery.isSuccess,
|
||||
rows.length,
|
||||
searchedKeyword,
|
||||
setSerpKeyword,
|
||||
setSerpPage,
|
||||
triggerSearch,
|
||||
uiState,
|
||||
]);
|
||||
|
||||
const { filteredRows, activeFilterCount } = useKeywordFiltering({
|
||||
@ -349,7 +305,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
return {
|
||||
activeFilterCount,
|
||||
activeSerpKeyword,
|
||||
beginSearch,
|
||||
clearSelection,
|
||||
controlsForm,
|
||||
filteredRows,
|
||||
@ -366,10 +321,9 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
||||
mobileTab: uiState.mobileTab,
|
||||
overviewKeyword,
|
||||
removeHistoryItem,
|
||||
resetResearch,
|
||||
researchError,
|
||||
researchMutationError,
|
||||
runSearch,
|
||||
retryResearch,
|
||||
resetFilters,
|
||||
rows,
|
||||
searchedKeyword,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
@ -35,47 +36,40 @@ export function AuthenticatedAppLayout({
|
||||
const location = useLocation();
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isSeoApiKeyConfigured, setIsSeoApiKeyConfigured] = React.useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [seoApiKeyStatusError, setSeoApiKeyStatusError] = React.useState(false);
|
||||
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
|
||||
React.useState(false);
|
||||
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
|
||||
const seoApiKeyStatusQuery = useQuery({
|
||||
queryKey: ["seoApiKeyStatus"],
|
||||
queryFn: () => getSeoApiKeyStatus(),
|
||||
enabled: shouldCheckSeoApiKeyStatus,
|
||||
});
|
||||
const isSeoApiKeyConfigured = shouldCheckSeoApiKeyStatus
|
||||
? (seoApiKeyStatusQuery.data?.configured ?? null)
|
||||
: null;
|
||||
const seoApiKeyStatusError =
|
||||
shouldCheckSeoApiKeyStatus && seoApiKeyStatusQuery.isError;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location.pathname === BILLING_ROUTE) {
|
||||
setSeoApiKeyStatusError(false);
|
||||
setIsSeoApiKeyConfigured(null);
|
||||
if (!shouldCheckSeoApiKeyStatus) {
|
||||
setShowMissingSeoApiKeyModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const checkSeoApiKeyStatus = async () => {
|
||||
try {
|
||||
const result = await getSeoApiKeyStatus();
|
||||
if (cancelled) return;
|
||||
|
||||
setSeoApiKeyStatusError(false);
|
||||
setIsSeoApiKeyConfigured(result.configured);
|
||||
if (!result.configured) {
|
||||
setShowMissingSeoApiKeyModal(true);
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setSeoApiKeyStatusError(true);
|
||||
setIsSeoApiKeyConfigured(null);
|
||||
if (seoApiKeyStatusQuery.isError) {
|
||||
setShowMissingSeoApiKeyModal(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
void checkSeoApiKeyStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
if (!seoApiKeyStatusQuery.isSuccess) return;
|
||||
setShowMissingSeoApiKeyModal(!seoApiKeyStatusQuery.data.configured);
|
||||
}, [
|
||||
location.pathname,
|
||||
seoApiKeyStatusQuery.data,
|
||||
seoApiKeyStatusQuery.isError,
|
||||
seoApiKeyStatusQuery.isSuccess,
|
||||
shouldCheckSeoApiKeyStatus,
|
||||
]);
|
||||
|
||||
const shouldShowMissingSeoApiKeyModal =
|
||||
showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user