diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts index a349aaa..381d0f7 100644 --- a/src/client/features/keywords/hooks/useKeywordResearchData.ts +++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts @@ -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([]); - const [hasSearched, setHasSearched] = useState(false); - const [lastSearchError, setLastSearchError] = useState(false); - const [lastResultSource, setLastResultSource] = - useState("related"); - const [lastUsedFallback, setLastUsedFallback] = useState(false); - const [lastSearchKeyword, setLastSearchKeyword] = useState(""); - const [lastSearchLocationCode, setLastSearchLocationCode] = useState( - DEFAULT_LOCATION_CODE, +type KeywordResearchQueryInput = { + projectId: string; + keywordInput: string; + locationCode: number; + resultLimit: ResultLimit; + mode: KeywordMode; +}; + +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 [researchError, setResearchError] = useState(null); - const [researchMutationError, setResearchMutationError] = - useState(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 request = useMemo(() => { + const seedKeyword = keywords[0] ?? ""; + if (!seedKeyword) return null; - const beginSearch = (seedKeyword: string, locationCode: number) => { - setResearchError(null); - setResearchMutationError(null); - setHasSearched(true); - setLastSearchError(false); - setSearchedKeyword(seedKeyword); - setLastSearchKeyword(seedKeyword); - setLastSearchLocationCode(locationCode); - setIsLoading(true); - }; + return { + projectId: input.projectId, + keywords, + seedKeyword, + locationCode: input.locationCode, + 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 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: { - projectId: string; - keywords: 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, - projectId: input.projectId, - locationCode: input.locationCode, - languageCode, - resultLimit: input.resultLimit, - mode: input.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, - }); - - if (seedKeyword) { - addSearch( - seedKeyword, - input.locationCode, - LOCATIONS[input.locationCode] || "Unknown", - ); + const researchQuery = useQuery({ + queryKey, + queryFn: () => { + if (!request) { + throw new Error("Keyword research query ran without request params"); } - 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); - } - }; + return researchKeywords({ + data: { + projectId: request.projectId, + keywords: request.keywords, + locationCode: request.locationCode, + languageCode: request.languageCode, + resultLimit: request.resultLimit, + mode: request.mode, + }, + }); + }, + enabled: request !== null, + staleTime: KEYWORD_RESEARCH_STALE_TIME_MS, + gcTime: KEYWORD_RESEARCH_STALE_TIME_MS, + retry: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + + const handledSuccessKeyRef = useRef(null); + useEffect(() => { + if (!request || !researchQuery.isSuccess || !researchQuery.data) return; + if (handledSuccessKeyRef.current === queryKeyString) return; + handledSuccessKeyRef.current = queryKeyString; + + 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, }; } diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index 4ad8dd5..3cd8278 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -81,10 +81,7 @@ function KeywordResearchContent({ Go to Billing ) : ( - )} diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index fea3be7..30f4316 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -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(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(null); + const handledSerpSearchKeyRef = useRef(null); - lastTriggerKeyRef.current = buildKeywordSearchKey(params); - uiState.setSelectedKeyword(null); - clearSelection(); - 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, - ], - ); + const clearActiveKeywordResult = useCallback(() => { + clearSelection(); + uiState.setSelectedKeyword(null); + setSerpKeyword(null); + setSerpPage(0); + }, [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, diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index 6c1b4c8..39f5257 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -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(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; + if (seoApiKeyStatusQuery.isError) { + setShowMissingSeoApiKeyModal(false); + return; + } - 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); - setShowMissingSeoApiKeyModal(false); - } - }; - - 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;