diff --git a/e2e/fixtures/keyword-research-fixtures.ts b/e2e/fixtures/keyword-research-fixtures.ts index aa53d9b..fa8bf84 100644 --- a/e2e/fixtures/keyword-research-fixtures.ts +++ b/e2e/fixtures/keyword-research-fixtures.ts @@ -1,5 +1,5 @@ import type { KeywordResearchRow } from "@/types/keywords"; -import type { ResearchKeywordsInput } from "@/types/schemas/keywords"; +import type { ResolvedResearchKeywordsInput } from "@/types/schemas/keywords"; const MONTHLY_SEARCHES = [ { year: 2025, month: 4, searchVolume: 1200 }, @@ -33,7 +33,7 @@ function makeRow( }; } -export function getKeywordResearchFixture(data: ResearchKeywordsInput) { +export function getKeywordResearchFixture(data: ResolvedResearchKeywordsInput) { const seedKeyword = data.keywords[0] ?? "keyword research"; const rows = [ makeRow(seedKeyword, 0, { diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index 70843d0..c782046 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -8,9 +8,7 @@ import { type DomainSearchParams, } from "@/types/schemas/domain"; import { - DEFAULT_LOCATION_CODE, LOCATIONS, - getLanguageCode, isLabsLocationCode, } from "@/client/features/keywords/locations"; import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory"; @@ -80,10 +78,13 @@ function getSortSearchUpdate( }; } -function getLocationSearchUpdate(nextLocationCode: number): DomainSearchUpdate { +function getLocationSearchUpdate( + nextLocationCode: number, + defaultLocationCode: number, +): DomainSearchUpdate { return { loc: - nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode, + nextLocationCode === defaultLocationCode ? undefined : nextLocationCode, page: undefined, }; } @@ -122,11 +123,12 @@ function getTabSearchUpdate( function getHistorySearchUpdate( item: DomainSearchHistoryItem, + defaultLocationCode: number, ): DomainSearchUpdate { const historyLocation = item.locationCode != null && isLabsLocationCode(item.locationCode) ? item.locationCode - : DEFAULT_LOCATION_CODE; + : defaultLocationCode; return { ...buildDomainFiltersClearSearchUpdate(), @@ -135,8 +137,7 @@ function getHistorySearchUpdate( sort: toSortSearchParam(item.sort), order: undefined, tab: item.tab === "keywords" ? undefined : item.tab, - loc: - historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation, + loc: historyLocation === defaultLocationCode ? undefined : historyLocation, size: undefined, }; } @@ -148,6 +149,7 @@ function getSearchSubmitUpdate({ locationCode, currentOrder, activeTab, + defaultLocationCode, }: { domain: string; subdomains: boolean; @@ -155,6 +157,7 @@ function getSearchSubmitUpdate({ locationCode: number; currentOrder: SortOrder; activeTab: DomainActiveTab; + defaultLocationCode: number; }): DomainSearchUpdate { return { ...buildDomainFiltersClearSearchUpdate(), @@ -163,7 +166,7 @@ function getSearchSubmitUpdate({ sort: toSortSearchParam(sort), order: toSortOrderSearchParam(sort, currentOrder), tab: activeTab === "keywords" ? undefined : activeTab, - loc: locationCode === DEFAULT_LOCATION_CODE ? undefined : locationCode, + loc: locationCode === defaultLocationCode ? undefined : locationCode, size: undefined, }; } @@ -205,9 +208,14 @@ function useDomainOverviewState({ const applyLocationChange = useCallback( (nextLocationCode: number) => { - setSearchParams(getLocationSearchUpdate(nextLocationCode)); + setSearchParams( + getLocationSearchUpdate( + nextLocationCode, + routeState.defaultLocationCode, + ), + ); }, - [setSearchParams], + [routeState.defaultLocationCode, setSearchParams], ); const handleSortColumnClick = useCallback( @@ -246,21 +254,21 @@ function useDomainOverviewState({ const handleHistorySelect = useCallback( (item: DomainSearchHistoryItem) => { - setSearchParams(getHistorySearchUpdate(item)); + setSearchParams( + getHistorySearchUpdate(item, routeState.defaultLocationCode), + ); }, - [setSearchParams], + [routeState.defaultLocationCode, setSearchParams], ); - const languageCode = getLanguageCode(routeState.locationCode); const overviewQuery = useDomainOverviewQuery({ projectId, domain: routeState.domain, includeSubdomains: routeState.subdomains, - locationCode: routeState.locationCode, - languageCode, + locationCode: routeState.sentLocationCode, }); const overview = overviewQuery.data ?? null; - const isLoading = overviewQuery.isLoading; + const isLoading = routeState.domain.trim() !== "" && overviewQuery.isLoading; const controlsForm = useForm({ defaultValues: { @@ -290,6 +298,7 @@ function useDomainOverviewState({ locationCode: value.locationCode, currentOrder: routeState.order, activeTab: routeState.tab, + defaultLocationCode: routeState.defaultLocationCode, }), ); }, @@ -389,7 +398,6 @@ function useDomainOverviewState({ history, historyLoaded, removeHistoryItem, - languageCode, setSearchParams, applySort, applyLocationChange, @@ -412,16 +420,20 @@ export function DomainOverviewPage({ navigate, onShowRecentSearches, }: Props) { - const state = useDomainOverviewState({ navigate, routeState, projectId }); + const state = useDomainOverviewState({ + navigate, + routeState, + projectId, + }); const urlTabInput = useMemo(() => { if (routeState.domain.trim() === "") return null; return { type: "domain", domain: routeState.domain, subdomains: routeState.subdomains, - locationCode: routeState.locationCode, + locationCode: routeState.sentLocationCode, }; - }, [routeState.domain, routeState.locationCode, routeState.subdomains]); + }, [routeState.domain, routeState.sentLocationCode, routeState.subdomains]); const navigateToSearchTab = useCallback( (input: SearchTabInput | null) => { @@ -443,10 +455,7 @@ export function DomainOverviewPage({ order: undefined, tab: undefined, page: undefined, - loc: - input.locationCode === DEFAULT_LOCATION_CODE - ? undefined - : input.locationCode, + loc: input.locationCode, size: undefined, }), replace: true, @@ -458,14 +467,18 @@ export function DomainOverviewPage({ const searchTabs = useSearchTabNavigation({ storageKey: `domain:${projectId}`, urlInput: urlTabInput, - getLabel: useCallback((input) => { - if (input.type !== "domain") return ""; - const locationSuffix = - input.locationCode === DEFAULT_LOCATION_CODE - ? "" - : ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`; - return `${input.domain}${locationSuffix}`; - }, []), + getLabel: useCallback( + (input) => { + if (input.type !== "domain") return ""; + const locationSuffix = + input.locationCode == null || + input.locationCode === routeState.defaultLocationCode + ? "" + : ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`; + return `${input.domain}${locationSuffix}`; + }, + [routeState.defaultLocationCode], + ), navigateToInput: navigateToSearchTab, }); @@ -623,7 +636,6 @@ export function DomainOverviewPage({ key="keywords" projectId={projectId} domain={state.overview.domain} - languageCode={state.languageCode} routeState={routeState} canSaveKeywords={state.canSaveKeywords} setSearchParams={state.setSearchParams} @@ -636,7 +648,6 @@ export function DomainOverviewPage({ key="pages" projectId={projectId} domain={state.overview.domain} - languageCode={state.languageCode} routeState={routeState} setSearchParams={state.setSearchParams} onSortClick={state.handleSortColumnClick} diff --git a/src/client/features/domain/components/KeywordsTab.tsx b/src/client/features/domain/components/KeywordsTab.tsx index 36702ba..2b76bcf 100644 --- a/src/client/features/domain/components/KeywordsTab.tsx +++ b/src/client/features/domain/components/KeywordsTab.tsx @@ -65,7 +65,6 @@ const KEYWORD_RANGE_FILTERS = [ type Props = { projectId: string; domain: string; - languageCode: string; routeState: DomainOverviewRouteState; canSaveKeywords: boolean; setSearchParams: (updates: SearchUpdate) => void; @@ -77,7 +76,6 @@ type Props = { export function KeywordsTab({ projectId, domain, - languageCode, routeState, canSaveKeywords, setSearchParams, @@ -106,8 +104,7 @@ export function KeywordsTab({ projectId, domain, includeSubdomains: routeState.subdomains, - locationCode: routeState.locationCode, - languageCode, + locationCode: routeState.sentLocationCode, page: routeState.page, pageSize: routeState.pageSize, sortMode: routeState.sort, @@ -159,13 +156,11 @@ export function KeywordsTab({ filteredKeywords: rows, save: saveMutation.mutate, projectId, - locationCode: routeState.locationCode, - languageCode, + locationCode: routeState.sentLocationCode, }); }, [ - languageCode, projectId, - routeState.locationCode, + routeState.sentLocationCode, rows, saveMutation.mutate, selectedKeywords, diff --git a/src/client/features/domain/components/PagesTab.tsx b/src/client/features/domain/components/PagesTab.tsx index 88dc522..cd1b8a1 100644 --- a/src/client/features/domain/components/PagesTab.tsx +++ b/src/client/features/domain/components/PagesTab.tsx @@ -55,7 +55,6 @@ const PAGE_RANGE_FILTERS = [ type Props = { projectId: string; domain: string; - languageCode: string; routeState: DomainOverviewRouteState; setSearchParams: (updates: SearchUpdate) => void; onSortClick: (sort: DomainSortMode) => void; @@ -66,7 +65,6 @@ type Props = { export function PagesTab({ projectId, domain, - languageCode, routeState, setSearchParams, onSortClick, @@ -98,8 +96,7 @@ export function PagesTab({ projectId, domain, includeSubdomains: routeState.subdomains, - locationCode: routeState.locationCode, - languageCode, + locationCode: routeState.sentLocationCode, page: routeState.page, pageSize: routeState.pageSize, sortMode: routeState.sort, diff --git a/src/client/features/domain/domainActions.ts b/src/client/features/domain/domainActions.ts index c00f683..a3e00bf 100644 --- a/src/client/features/domain/domainActions.ts +++ b/src/client/features/domain/domainActions.ts @@ -6,8 +6,7 @@ import type { KeywordRow } from "@/client/features/domain/types"; type SaveMutation = (payload: { projectId: string; keywords: string[]; - locationCode: number; - languageCode: string; + locationCode?: number; metrics?: Array<{ keyword: string; searchVolume?: number | null; @@ -27,14 +26,12 @@ export function saveSelectedKeywords({ save, projectId, locationCode, - languageCode, }: { selectedKeywords: Set; filteredKeywords: KeywordRow[]; save: (payload: Parameters[0], opts?: SaveOptions) => void; projectId: string; - locationCode: number; - languageCode: string; + locationCode?: number; }) { if (selectedKeywords.size === 0) { toast.error("Select at least one keyword first"); @@ -49,7 +46,6 @@ export function saveSelectedKeywords({ projectId, keywords: [...selectedKeywords], locationCode, - languageCode, metrics: selectedRows.map((row) => ({ keyword: row.keyword, searchVolume: row.searchVolume, diff --git a/src/client/features/domain/domainRouteState.test.ts b/src/client/features/domain/domainRouteState.test.ts new file mode 100644 index 0000000..726a427 --- /dev/null +++ b/src/client/features/domain/domainRouteState.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { getDomainRouteState } from "./domainRouteState"; + +describe("getDomainRouteState", () => { + it("uses a Labs-backed project market when the URL omits loc", () => { + const state = getDomainRouteState( + {}, + { locationCode: 2704, languageCode: "vi" }, + ); + + expect(state.defaultLocationCode).toBe(2704); + expect(state.locationCode).toBe(2704); + expect(state.sentLocationCode).toBeUndefined(); + }); + + it("keeps an explicit Labs-backed URL location", () => { + const state = getDomainRouteState( + { loc: 2840 }, + { locationCode: 2704, languageCode: "vi" }, + ); + + expect(state.defaultLocationCode).toBe(2704); + expect(state.locationCode).toBe(2840); + expect(state.sentLocationCode).toBe(2840); + }); + + it("falls back to US for a Google-Ads-only project market", () => { + const state = getDomainRouteState( + {}, + { locationCode: 2352, languageCode: "is" }, + ); + + expect(state.defaultLocationCode).toBe(2840); + expect(state.locationCode).toBe(2840); + expect(state.sentLocationCode).toBeUndefined(); + }); + + it("ignores a Google-Ads-only URL location", () => { + const state = getDomainRouteState( + { loc: 2352 }, + { locationCode: 2704, languageCode: "vi" }, + ); + + expect(state.defaultLocationCode).toBe(2704); + expect(state.locationCode).toBe(2704); + expect(state.sentLocationCode).toBe(2352); + }); +}); diff --git a/src/client/features/domain/domainRouteState.ts b/src/client/features/domain/domainRouteState.ts index b243fbb..8180389 100644 --- a/src/client/features/domain/domainRouteState.ts +++ b/src/client/features/domain/domainRouteState.ts @@ -6,6 +6,7 @@ import { DEFAULT_LOCATION_CODE, isLabsLocationCode, } from "@/client/features/keywords/locations"; +import type { ProjectMarket } from "@/client/features/projects/types"; import { EMPTY_DOMAIN_FILTERS, type DomainActiveTab, @@ -28,7 +29,9 @@ export type DomainOverviewRouteState = { sort: DomainSortMode; order: SortOrder; tab: DomainActiveTab; + defaultLocationCode: number; locationCode: number; + sentLocationCode: number | undefined; page: number; pageSize: number; appliedFilters: DomainFilterValues; @@ -44,13 +47,18 @@ function numberToFilterString(value: number | undefined): string { export function getDomainRouteState( search: DomainSearchParams, + projectMarket?: ProjectMarket, ): DomainOverviewRouteState { const normalizedSort = toSortMode(search.sort ?? null) ?? "traffic"; + const defaultLocationCode = + projectMarket && isLabsLocationCode(projectMarket.locationCode) + ? projectMarket.locationCode + : DEFAULT_LOCATION_CODE; // Domain analytics is Labs-backed; Google-Ads-only countries aren't valid. const normalizedLocationCode = search.loc != null && isLabsLocationCode(search.loc) ? search.loc - : DEFAULT_LOCATION_CODE; + : defaultLocationCode; return { domain: search.domain ?? "", @@ -58,7 +66,9 @@ export function getDomainRouteState( sort: normalizedSort, order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)), tab: search.tab ?? "keywords", + defaultLocationCode, locationCode: normalizedLocationCode, + sentLocationCode: search.loc, page: search.page != null && search.page > 0 ? search.page : 1, pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE, appliedFilters: { diff --git a/src/client/features/domain/hooks/useDomainKeywordsQuery.ts b/src/client/features/domain/hooks/useDomainKeywordsQuery.ts index 3c2699c..1e69efb 100644 --- a/src/client/features/domain/hooks/useDomainKeywordsQuery.ts +++ b/src/client/features/domain/hooks/useDomainKeywordsQuery.ts @@ -12,8 +12,7 @@ type DomainKeywordsQueryInput = { projectId: string; domain: string; includeSubdomains: boolean; - locationCode: number; - languageCode: string; + locationCode: number | undefined; page: number; pageSize: number; sortMode: DomainSortMode; @@ -60,7 +59,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) { input.domain, input.includeSubdomains, input.locationCode, - input.languageCode, input.page, input.pageSize, input.sortMode, @@ -71,7 +69,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) { filtersPayload, input.domain, input.includeSubdomains, - input.languageCode, input.locationCode, input.page, input.pageSize, @@ -98,7 +95,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) { domain: input.domain, includeSubdomains: input.includeSubdomains, locationCode: input.locationCode, - languageCode: input.languageCode, page: input.page, pageSize: input.pageSize, sortMode: input.sortMode, diff --git a/src/client/features/domain/hooks/useDomainOverviewQuery.ts b/src/client/features/domain/hooks/useDomainOverviewQuery.ts index 28a7abe..a6cea65 100644 --- a/src/client/features/domain/hooks/useDomainOverviewQuery.ts +++ b/src/client/features/domain/hooks/useDomainOverviewQuery.ts @@ -5,8 +5,7 @@ type Input = { projectId: string; domain: string; includeSubdomains: boolean; - locationCode: number; - languageCode: string; + locationCode: number | undefined; }; export function useDomainOverviewQuery(input: Input) { @@ -20,7 +19,6 @@ export function useDomainOverviewQuery(input: Input) { trimmedDomain, input.includeSubdomains, input.locationCode, - input.languageCode, ], queryFn: () => getDomainOverview({ @@ -29,7 +27,6 @@ export function useDomainOverviewQuery(input: Input) { domain: trimmedDomain, includeSubdomains: input.includeSubdomains, locationCode: input.locationCode, - languageCode: input.languageCode, }, }), staleTime: 5 * 60_000, diff --git a/src/client/features/domain/hooks/useDomainPagesQuery.ts b/src/client/features/domain/hooks/useDomainPagesQuery.ts index fe11694..28a433c 100644 --- a/src/client/features/domain/hooks/useDomainPagesQuery.ts +++ b/src/client/features/domain/hooks/useDomainPagesQuery.ts @@ -13,8 +13,7 @@ type DomainPagesQueryInput = { projectId: string; domain: string; includeSubdomains: boolean; - locationCode: number; - languageCode: string; + locationCode: number | undefined; page: number; pageSize: number; sortMode: DomainSortMode; @@ -32,7 +31,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) { input.domain, input.includeSubdomains, input.locationCode, - input.languageCode, input.page, input.pageSize, pageSortMode, @@ -43,7 +41,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) { input.appliedFilters, input.domain, input.includeSubdomains, - input.languageCode, input.locationCode, input.page, input.pageSize, @@ -70,7 +67,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) { domain: input.domain, includeSubdomains: input.includeSubdomains, locationCode: input.locationCode, - languageCode: input.languageCode, page: input.page, pageSize: input.pageSize, sortMode: pageSortMode, diff --git a/src/client/features/domain/mutations.ts b/src/client/features/domain/mutations.ts index 3ad740e..6d7ae4f 100644 --- a/src/client/features/domain/mutations.ts +++ b/src/client/features/domain/mutations.ts @@ -12,8 +12,7 @@ export function useSaveKeywordsMutation({ mutationFn: (data: { projectId: string; keywords: string[]; - locationCode: number; - languageCode: string; + locationCode?: number; metrics?: Array<{ keyword: string; searchVolume?: number | null; diff --git a/src/client/features/keywords/hooks/useKeywordControlsForm.ts b/src/client/features/keywords/hooks/useKeywordControlsForm.ts index b9b19b9..92d381a 100644 --- a/src/client/features/keywords/hooks/useKeywordControlsForm.ts +++ b/src/client/features/keywords/hooks/useKeywordControlsForm.ts @@ -13,7 +13,7 @@ import { parseKeywordInput } from "@/client/features/keywords/state/keywordContr type KeywordTabValidationInput = { keyword: string; - locationCode: number; + locationCode: number | undefined; resultLimit: ResultLimit; mode: KeywordMode; clickstream: boolean; diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.test.ts b/src/client/features/keywords/hooks/useKeywordResearchData.test.ts new file mode 100644 index 0000000..7e414cb --- /dev/null +++ b/src/client/features/keywords/hooks/useKeywordResearchData.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; + +// The hook module pulls in the server functions it calls, whose graph reaches +// Workers-only bindings that don't resolve outside workerd. +vi.mock("cloudflare:workers", () => ({ env: {} })); + +import { buildKeywordResearchRequest } from "./useKeywordResearchData"; + +const baseInput = { + projectId: "project_1", + keywordInput: "technical seo", + locationCode: 2704, + resultLimit: 150 as const, + mode: "auto" as const, + clickstream: false, +}; + +describe("buildKeywordResearchRequest", () => { + it("carries an explicitly selected location without a language", () => { + const request = buildKeywordResearchRequest(baseInput); + + expect(request).toMatchObject({ locationCode: 2704 }); + expect(request).not.toHaveProperty("languageCode"); + }); + + it("leaves the location undefined for the server to resolve", () => { + const request = buildKeywordResearchRequest({ + ...baseInput, + locationCode: undefined, + }); + + expect(request).toMatchObject({ locationCode: undefined }); + expect(request).not.toHaveProperty("languageCode"); + }); +}); diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts index a2a08e7..f93083b 100644 --- a/src/client/features/keywords/hooks/useKeywordResearchData.ts +++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts @@ -2,8 +2,7 @@ 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"; -import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; +import { LOCATIONS } from "@/client/features/keywords/utils"; import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions"; import { researchKeywords } from "@/serverFunctions/keywords"; import type { @@ -18,21 +17,24 @@ type AddSearchFn = ( locationName: string, ) => void; -type KeywordResearchQueryInput = { +type KeywordResearchRequestInput = { projectId: string; keywordInput: string; - locationCode: number; + locationCode: number | undefined; resultLimit: ResultLimit; mode: KeywordMode; clickstream: boolean; }; +type KeywordResearchQueryInput = KeywordResearchRequestInput & { + displayedLocationCode: number; +}; + type KeywordResearchRequest = { projectId: string; keywords: string[]; seedKeyword: string; - locationCode: number; - languageCode: string; + locationCode: number | undefined; resultLimit: ResultLimit; mode: KeywordMode; clickstream: boolean; @@ -41,7 +43,7 @@ type KeywordResearchRequest = { export const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000; export function buildKeywordResearchRequest( - input: KeywordResearchQueryInput, + input: KeywordResearchRequestInput, ): KeywordResearchRequest | null { const keywords = parseKeywordInput(input.keywordInput); const seedKeyword = keywords[0] ?? ""; @@ -52,7 +54,6 @@ export function buildKeywordResearchRequest( keywords, seedKeyword, locationCode: input.locationCode, - languageCode: getLanguageCode(input.locationCode), resultLimit: input.resultLimit, mode: input.mode, clickstream: input.clickstream, @@ -68,7 +69,6 @@ export function buildKeywordResearchQueryKey( request.projectId, request.keywords, request.locationCode, - request.languageCode, request.resultLimit, request.mode, request.clickstream, @@ -82,7 +82,6 @@ export function keywordResearchQueryFn(request: KeywordResearchRequest) { projectId: request.projectId, keywords: request.keywords, locationCode: request.locationCode, - languageCode: request.languageCode, resultLimit: request.resultLimit, mode: request.mode, clickstream: request.clickstream, @@ -96,6 +95,7 @@ export function useKeywordResearchData( ) { const { clickstream, + displayedLocationCode, keywordInput, locationCode, mode, @@ -144,7 +144,7 @@ export function useKeywordResearchData( handledSuccessKeyRef.current = queryKeyString; captureClientEvent("keyword_research:search_complete", { - location_code: request.locationCode, + location_code: displayedLocationCode, search_mode: request.mode, clickstream: request.clickstream, result_count: researchQuery.data.rows.length, @@ -152,18 +152,19 @@ export function useKeywordResearchData( addSearch( request.seedKeyword, - request.locationCode, - LOCATIONS[request.locationCode] || "Unknown", + displayedLocationCode, + LOCATIONS[displayedLocationCode] || "Unknown", ); }, [ addSearch, + displayedLocationCode, queryKeyString, request, researchQuery.data, researchQuery.isSuccess, ]); - const hasSearched = request !== null; + const hasSearched = parseKeywordInput(keywordInput).length > 0; const rows = hasSearched ? (researchQuery.data?.rows ?? []) : []; const researchError = hasSearched && researchQuery.isError @@ -178,7 +179,7 @@ export function useKeywordResearchData( researchQuery.data?.source ?? ("related" as ResearchSource), lastUsedFallback: researchQuery.data?.usedFallback ?? false, lastSearchKeyword: request?.seedKeyword ?? "", - lastSearchLocationCode: request?.locationCode ?? DEFAULT_LOCATION_CODE, + lastSearchLocationCode: displayedLocationCode, researchError, researchMutationError: researchQuery.error, searchedKeyword: request?.seedKeyword ?? "", diff --git a/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts b/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts index 016fdfc..6f01ce1 100644 --- a/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts +++ b/src/client/features/keywords/hooks/useKeywordSerpAnalysis.ts @@ -1,12 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; -import { getLanguageCode } from "@/client/features/keywords/utils"; import { getSerpAnalysis } from "@/serverFunctions/keywords"; export function useKeywordSerpAnalysis( projectId: string, - locationCode: number, + locationCode: number | undefined, ) { const [serpKeyword, setSerpKeyword] = useState(null); const [serpPage, setSerpPage] = useState(0); @@ -20,7 +19,6 @@ export function useKeywordSerpAnalysis( projectId, keyword: serpKeyword!, locationCode, - languageCode: getLanguageCode(locationCode), }, }), enabled: !!serpKeyword, @@ -29,7 +27,7 @@ export function useKeywordSerpAnalysis( const serpResults = serpQuery.data?.items ?? []; const activeSerpKeyword = serpKeyword ?? serpQuery.data?.requestedKeyword ?? null; - const serpLoading = serpQuery.isLoading; + const serpLoading = !!serpKeyword && serpQuery.isLoading; const serpError = serpQuery.isError ? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.") : null; diff --git a/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts b/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts index a27dcd4..0e84c9f 100644 --- a/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts +++ b/src/client/features/keywords/hooks/usePreferredKeywordLocation.ts @@ -5,12 +5,15 @@ import { isSupportedLocationCode, } from "@/client/features/keywords/locations"; -const STORAGE_KEY = "keyword-preferred-location"; +// Scoped per project: a location picked while working on one project must not +// shadow another project's own default market. +const storageKey = (projectId: string) => + `keyword-preferred-location:${projectId}`; const locationCodeSchema = z.number().int().positive(); -function loadPreferredLocationCode() { +function loadPreferredLocationCode(projectId: string) { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(storageKey(projectId)); if (!raw) return null; const parsed = locationCodeSchema.parse(JSON.parse(raw)); @@ -20,31 +23,49 @@ function loadPreferredLocationCode() { } } -function savePreferredLocationCode(locationCode: number) { +function savePreferredLocationCode(projectId: string, locationCode: number) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(locationCode)); + localStorage.setItem(storageKey(projectId), JSON.stringify(locationCode)); } catch { // storage full or unavailable - silently ignore } } -export function usePreferredKeywordLocation() { - const [preferredLocationCode, setPreferredLocationCodeState] = useState( - DEFAULT_LOCATION_CODE, - ); +/** + * Preference order: the user's explicit choice for this project (persisted per + * browser) > the project's default market (may arrive async from the projects + * query) > the US fallback. + */ +export function usePreferredKeywordLocation( + projectId: string, + projectDefaultLocationCode?: number, +) { + const [preference, setPreference] = useState(() => ({ + projectId, + locationCode: loadPreferredLocationCode(projectId), + })); + const chosenLocationCode = + preference.projectId === projectId + ? preference.locationCode + : loadPreferredLocationCode(projectId); useEffect(() => { - const savedLocationCode = loadPreferredLocationCode(); - if (savedLocationCode != null) { - setPreferredLocationCodeState(savedLocationCode); - } - }, []); + if (preference.projectId === projectId) return; + setPreference({ projectId, locationCode: chosenLocationCode }); + }, [chosenLocationCode, preference.projectId, projectId]); + + const preferredLocationCode = + chosenLocationCode ?? projectDefaultLocationCode ?? DEFAULT_LOCATION_CODE; function setPreferredLocationCode(locationCode: number) { if (!isSupportedLocationCode(locationCode)) return; - setPreferredLocationCodeState(locationCode); - savePreferredLocationCode(locationCode); + setPreference({ projectId, locationCode }); + savePreferredLocationCode(projectId, locationCode); } - return { preferredLocationCode, setPreferredLocationCode }; + return { + preferredLocationCode, + selectedLocationCode: chosenLocationCode ?? undefined, + setPreferredLocationCode, + }; } diff --git a/src/client/features/keywords/page/KeywordResearchEmptyState.tsx b/src/client/features/keywords/page/KeywordResearchEmptyState.tsx index 7ffbfcb..1453289 100644 --- a/src/client/features/keywords/page/KeywordResearchEmptyState.tsx +++ b/src/client/features/keywords/page/KeywordResearchEmptyState.tsx @@ -1,6 +1,5 @@ import { Link } from "@tanstack/react-router"; import { Clock, Globe, History, Search, X } from "lucide-react"; -import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; import { LOCATIONS } from "@/client/features/keywords/utils"; import type { KeywordResearchControllerState } from "./types"; @@ -89,10 +88,7 @@ function SearchHistoryState({ params={{ projectId }} search={{ q: item.keyword, - loc: - item.locationCode === DEFAULT_LOCATION_CODE - ? undefined - : item.locationCode, + loc: item.locationCode, }} replace className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200" diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index 8442fb5..a9ea173 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -7,21 +7,30 @@ import { useKeywordResearchController } from "@/client/features/keywords/state/u 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 } from "@/client/features/keywords/state/keywordControllerInternals"; -import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; +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 { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; +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 Props = Omit; +type ControllerProps = Omit; +type Props = Omit< + ControllerProps, + "locationCode" | "displayedLocationCode" | "setPreferredLocationCode" +> & { locationCode?: number }; type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput }; function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab { @@ -31,6 +40,11 @@ function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab { 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) => { @@ -47,10 +61,7 @@ export function KeywordResearchPage(input: Props) { setSearchParams({ q: tabInput.keyword, - loc: - tabInput.locationCode === DEFAULT_LOCATION_CODE - ? undefined - : tabInput.locationCode, + loc: tabInput.locationCode, kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit, mode: tabInput.mode === "auto" ? undefined : tabInput.mode, cs: tabInput.clickstream ? true : undefined, @@ -66,7 +77,7 @@ export function KeywordResearchPage(input: Props) { return { type: "keyword", keyword, - locationCode: input.locationCode, + locationCode, resultLimit: input.resultLimit, mode: input.keywordMode, clickstream: input.clickstream, @@ -75,7 +86,7 @@ export function KeywordResearchPage(input: Props) { input.clickstream, input.keywordInput, input.keywordMode, - input.locationCode, + locationCode, input.resultLimit, ]); const searchTabs = useSearchTabNavigation({ @@ -98,7 +109,13 @@ export function KeywordResearchPage(input: Props) { const tab = searchTabs.tabs.find( (candidate) => candidate.id === searchTabs.activeTabId, ); - return tab && isKeywordSearchTab(tab) ? tab : null; + // 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( @@ -148,14 +165,16 @@ export function KeywordResearchPage(input: Props) { [searchTabs.tabs], ); - const controllerInput = useMemo( + const controllerInput = useMemo( () => activeTab ? { ...input, keywordInput: activeTab.input.keyword, locationCode: activeTab.input.locationCode, - hasExplicitLocationCode: true, + displayedLocationCode: + activeTab.input.locationCode ?? displayedLocationCode, + setPreferredLocationCode, resultLimit: activeTab.input.resultLimit, keywordMode: activeTab.input.mode, clickstream: activeTab.input.clickstream, @@ -164,10 +183,21 @@ export function KeywordResearchPage(input: Props) { } : { ...input, + locationCode, + displayedLocationCode, + setPreferredLocationCode, getOpenKeywordTabs, keywordTabsLimit: searchTabs.limit, }, - [activeTab, getOpenKeywordTabs, input, searchTabs.limit], + [ + activeTab, + getOpenKeywordTabs, + input, + displayedLocationCode, + locationCode, + searchTabs.limit, + setPreferredLocationCode, + ], ); const controller = useKeywordResearchController({ ...controllerInput, diff --git a/src/client/features/keywords/state/keywordControllerActions.ts b/src/client/features/keywords/state/keywordControllerActions.ts index 192a2a5..f11c668 100644 --- a/src/client/features/keywords/state/keywordControllerActions.ts +++ b/src/client/features/keywords/state/keywordControllerActions.ts @@ -3,7 +3,6 @@ import { toast } from "sonner"; import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; -import { getLanguageCode } from "@/client/features/keywords/utils"; import type { KeywordResearchRow } from "@/types/keywords"; import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { SortDir, SortField } from "@/client/features/keywords/components"; @@ -62,7 +61,7 @@ export function parseKeywordInput(value: string) { */ export function buildKeywordSearchKey(params: { keyword: string; - locationCode: number; + locationCode: number | undefined; resultLimit: ResultLimit; mode: KeywordMode; clickstream: boolean; @@ -127,7 +126,6 @@ export function useSaveAndExportActions(params: SaveExportActionParams) { projectId: input.projectId, keywords: [...selectedRows], locationCode: input.locationCode, - languageCode: getLanguageCode(input.locationCode), metrics, }, { diff --git a/src/client/features/keywords/state/keywordControllerInternals.ts b/src/client/features/keywords/state/keywordControllerInternals.ts index 13e59d6..affbd45 100644 --- a/src/client/features/keywords/state/keywordControllerInternals.ts +++ b/src/client/features/keywords/state/keywordControllerInternals.ts @@ -2,22 +2,25 @@ import { useNavigate } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation"; +import { useProjectMarket } from "@/client/features/projects/useProjectMarket"; import { saveKeywords } from "@/serverFunctions/keywords"; import type { SaveKeywordsInput } from "@/types/schemas/keywords"; import type { KeywordResearchRow } from "@/types/keywords"; -import type { KeywordResearchControllerInput } from "./useKeywordResearchController"; -export function useResolvedKeywordLocation( - input: KeywordResearchControllerInput, -) { - const { preferredLocationCode, setPreferredLocationCode } = - usePreferredKeywordLocation(); - const locationCode = - !input.hasExplicitLocationCode && input.keywordInput === "" - ? preferredLocationCode - : input.locationCode; +export function useResolvedKeywordLocation(input: { + projectId: string; + locationCode?: number; +}) { + const projectMarket = useProjectMarket(input.projectId); + const { + preferredLocationCode, + selectedLocationCode, + setPreferredLocationCode, + } = usePreferredKeywordLocation(input.projectId, projectMarket?.locationCode); + const locationCode = input.locationCode ?? selectedLocationCode; + const displayedLocationCode = input.locationCode ?? preferredLocationCode; - return { locationCode, setPreferredLocationCode }; + return { locationCode, displayedLocationCode, setPreferredLocationCode }; } export function useKeywordUiState(initialShowFilters: boolean) { diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index 3cbc1e8..9fed9c1 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -25,13 +25,12 @@ import { useKeywordSaveMutation, useKeywordSearchParams, useKeywordUiState, - useResolvedKeywordLocation, } from "./keywordControllerInternals"; import { useKeywordOverviewState } from "./useKeywordOverviewState"; type OpenKeywordTabInput = { keyword: string; - locationCode: number; + locationCode: number | undefined; resultLimit: ResultLimit; mode: KeywordMode; clickstream: boolean; @@ -40,8 +39,9 @@ type OpenKeywordTabInput = { export type KeywordResearchControllerInput = { projectId: string; keywordInput: string; - locationCode: number; - hasExplicitLocationCode: boolean; + locationCode: number | undefined; + displayedLocationCode: number; + setPreferredLocationCode: (locationCode: number) => void; resultLimit: ResultLimit; keywordMode: KeywordMode; clickstream: boolean; @@ -60,8 +60,8 @@ export type KeywordResearchControllerInput = { export function useKeywordResearchController( input: KeywordResearchControllerInput, ) { - const { locationCode, setPreferredLocationCode } = - useResolvedKeywordLocation(input); + const { displayedLocationCode, locationCode, setPreferredLocationCode } = + input; const { filtersForm, values: filterValues, @@ -115,6 +115,7 @@ export function useKeywordResearchController( projectId: input.projectId, keywordInput: input.keywordInput, locationCode, + displayedLocationCode, resultLimit: input.resultLimit, mode: input.keywordMode, clickstream: input.clickstream, @@ -148,7 +149,7 @@ export function useKeywordResearchController( const controlsForm = useKeywordControlsForm( { ...input, - locationCode, + locationCode: displayedLocationCode, getOpenKeywordTabs: input.getOpenKeywordTabs, keywordTabsLimit: input.keywordTabsLimit, }, diff --git a/src/client/features/keywords/utils.ts b/src/client/features/keywords/utils.ts index 4bb6448..696d14b 100644 --- a/src/client/features/keywords/utils.ts +++ b/src/client/features/keywords/utils.ts @@ -1,4 +1,4 @@ -export { LOCATIONS, getLanguageCode } from "./locations"; +export { LOCATIONS } from "./locations"; export function scoreTierClass(value: number | null): string { if (value == null) return "score-tier-na"; diff --git a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx index e1b5151..6205cb0 100644 --- a/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx +++ b/src/client/features/onboarding/SearchConsoleOnboardingStep.tsx @@ -11,12 +11,14 @@ import { 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 } from "@/serverFunctions/projects"; +import { getProjects, setProjectMarket } from "@/serverFunctions/projects"; const GRANT_STATUS_KEY = ["gscGrantStatus"]; @@ -31,19 +33,66 @@ export function SearchConsoleOnboardingStep() { queryKey: ["projects"], queryFn: () => getProjects(), }); - const projectId = projectsQuery.data?.[0]?.id; + const project = projectsQuery.data?.[0]; return ( -
-

- Connect with Google Search Console now? -

+
+
+

+ Connect with Google Search Console now? +

- {projectId ? : } + {project ? : } +

+ For now, Search Console data flows through the OpenSEO MCP. We're + building it into the OpenSEO app soon too. +

+
+ +
+

Choose country & language

+ {project ? : } +
+
+ ); +} + +/** + * 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({ + 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 ( +
+

- For now, Search Console data flows through the OpenSEO MCP. We're - building it into the OpenSEO app soon too. + 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.

); diff --git a/src/client/features/projects/CreateProjectModal.tsx b/src/client/features/projects/CreateProjectModal.tsx index 23f248e..0f1f1e5 100644 --- a/src/client/features/projects/CreateProjectModal.tsx +++ b/src/client/features/projects/CreateProjectModal.tsx @@ -5,6 +5,11 @@ import { toast } from "sonner"; import { Modal } from "@/client/components/Modal"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { setLastProjectId } from "@/client/lib/active-project"; +import { + DEFAULT_LOCATION_CODE, + getLanguageCode, +} from "@/client/features/keywords/locations"; +import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields"; import { createProject } from "@/serverFunctions/projects"; export function CreateProjectModal({ onClose }: { onClose: () => void }) { @@ -12,11 +17,19 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient(); const [name, setName] = React.useState(""); const [domain, setDomain] = React.useState(""); + const [market, setMarket] = React.useState({ + locationCode: DEFAULT_LOCATION_CODE, + languageCode: getLanguageCode(DEFAULT_LOCATION_CODE), + }); const createMutation = useMutation({ mutationFn: () => createProject({ - data: { name: name.trim(), domain: domain.trim() || undefined }, + data: { + name: name.trim(), + domain: domain.trim() || undefined, + ...market, + }, }), onSuccess: async (created) => { setLastProjectId(created.id); @@ -88,6 +101,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { +
+ + + Keyword, SERP, and domain data uses this country and language unless + a call asks for a different one. Change it later in project + settings. + +
+