feat: configurable default market for location/language fallbacks (#72)

This commit is contained in:
bookingseo 2026-07-16 21:46:49 +07:00 committed by GitHub
parent c1121bdcab
commit 0f08437cd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1490 additions and 363 deletions

View File

@ -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, {

View File

@ -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<SearchTabInput | null>(() => {
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) => {
getLabel: useCallback(
(input) => {
if (input.type !== "domain") return "";
const locationSuffix =
input.locationCode === DEFAULT_LOCATION_CODE
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}

View File

@ -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,

View File

@ -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,

View File

@ -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<string>;
filteredKeywords: KeywordRow[];
save: (payload: Parameters<SaveMutation>[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,

View File

@ -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);
});
});

View File

@ -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: {

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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");
});
});

View File

@ -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 ?? "",

View File

@ -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<string | null>(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;

View File

@ -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,
};
}

View File

@ -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"

View File

@ -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<KeywordResearchControllerInput, "onFormSubmit">;
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 {
@ -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<Props>(
const controllerInput = useMemo<ControllerProps>(
() =>
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,

View File

@ -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,
},
{

View File

@ -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) {

View File

@ -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,
},

View File

@ -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";

View File

@ -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,21 +33,68 @@ export function SearchConsoleOnboardingStep() {
queryKey: ["projects"],
queryFn: () => getProjects(),
});
const projectId = projectsQuery.data?.[0]?.id;
const project = projectsQuery.data?.[0];
return (
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-lg font-semibold">
Connect with Google Search Console now?
</h2>
{projectId ? <GscConnect projectId={projectId} /> : <Checking />}
{project ? <GscConnect projectId={project.id} /> : <Checking />}
<p className="text-xs leading-relaxed text-base-content/55">
For now, Search Console data flows through the OpenSEO MCP. We're
building it into the OpenSEO app soon too.
</p>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Choose country &amp; language</h2>
{project ? <DefaultMarketPicker project={project} /> : <Checking />}
</div>
</div>
);
}
/**
* 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<ProjectMarket>({
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 (
<div className="space-y-2">
<ProjectMarketFields value={market} onChange={handleChange} />
<p className="text-xs leading-relaxed text-base-content/55">
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.
</p>
</div>
);
}

View File

@ -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 }) {
</span>
</label>
<div className="flex flex-col gap-1.5">
<ProjectMarketFields value={market} onChange={setMarket} />
<span className="text-xs text-base-content/50">
Keyword, SERP, and domain data uses this country and language unless
a call asks for a different one. Change it later in project
settings.
</span>
</div>
<div className="flex justify-end gap-2">
<button
type="button"

View File

@ -0,0 +1,58 @@
import { LocationSelect } from "@/client/components/LocationSelect";
import {
getLanguageCode,
getLanguageOptions,
} from "@/client/features/keywords/locations";
import type { ProjectMarket } from "@/client/features/projects/types";
/**
* The project's default market: country plus the language served for it.
* Shared by project settings and onboarding so the pair and the rule that
* changing the country snaps the language to that country's native one
* stays identical in both places.
*/
export function ProjectMarketFields({
value,
onChange,
}: {
value: ProjectMarket;
onChange: (market: ProjectMarket) => void;
}) {
const languageOptions = getLanguageOptions(value.locationCode);
return (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Country</span>
<LocationSelect
value={value.locationCode}
onChange={(locationCode) =>
onChange({
locationCode,
languageCode: getLanguageCode(locationCode),
})
}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">Language</span>
<select
value={value.languageCode}
onChange={(event) =>
onChange({ ...value, languageCode: event.target.value })
}
// Most countries have exactly one language DataForSEO serves, so the
// select is only a real choice where there's more than one.
disabled={languageOptions.length <= 1}
className="select select-bordered w-full"
>
{languageOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</label>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft } from "lucide-react";
import { toast } from "sonner";
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
clearLastProjectId,
@ -69,6 +70,10 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
const queryClient = useQueryClient();
const [name, setName] = React.useState(project.name);
const [domain, setDomain] = React.useState(project.domain ?? "");
const [market, setMarket] = React.useState({
locationCode: project.locationCode,
languageCode: project.languageCode,
});
const updateMutation = useMutation({
mutationFn: () =>
@ -77,6 +82,7 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
projectId: project.id,
name: name.trim(),
domain: domain.trim() || undefined,
...market,
},
}),
onSuccess: async () => {
@ -89,7 +95,9 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
const isDirty =
name.trim() !== project.name ||
(domain.trim() || "") !== (project.domain ?? "");
(domain.trim() || "") !== (project.domain ?? "") ||
market.locationCode !== project.locationCode ||
market.languageCode !== project.languageCode;
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
@ -130,6 +138,14 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
/>
</label>
<div className="flex flex-col gap-1.5">
<ProjectMarketFields value={market} onChange={setMarket} />
<span className="text-xs text-base-content/50">
Keyword, SERP, and domain data uses this country and language unless
a call asks for a different one.
</span>
</div>
<div className="flex justify-end">
<button
type="submit"

View File

@ -1,7 +1,14 @@
// The project's default market: the country/language pair its data calls
// fall back to. Mirrors resolveMarket's project argument in shared/.
export type ProjectMarket = { locationCode: number; languageCode: string };
// Shape returned by the getProjects server function (a mapped project row).
export type ProjectSummary = {
id: string;
name: string;
domain: string | null;
// Default market for the project's data calls.
locationCode: number;
languageCode: string;
createdAt: string;
};

View File

@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getProjects } from "@/serverFunctions/projects";
import type { ProjectMarket } from "./types";
/** The project's default market, or undefined until the projects query resolves. */
export function useProjectMarket(projectId: string): ProjectMarket | undefined {
const projectsQuery = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
});
return projectsQuery.data?.find((project) => project.id === projectId);
}

View File

@ -140,7 +140,6 @@ export function KeywordSuggestionStep({
projectId,
domain,
locationCode,
languageCode,
onDone,
onClose,
}: Props) {
@ -163,16 +162,10 @@ export function KeywordSuggestionStep({
// Ads keyword data (e.g. Iceland) have no ranking data to suggest from.
const labsSupported = isLabsLocationCode(locationCode);
const suggestionsQuery = useQuery({
queryKey: [
"domainKeywordSuggestions",
projectId,
domain,
locationCode,
languageCode,
],
queryKey: ["domainKeywordSuggestions", projectId, domain, locationCode],
queryFn: () =>
getDomainKeywordSuggestions({
data: { projectId, domain, locationCode, languageCode },
data: { projectId, domain, locationCode },
}),
enabled: labsSupported,
});

View File

@ -10,12 +10,13 @@ import {
estimateRankCheckCredits,
} from "@/shared/rank-tracking";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
getLanguageOptions,
} from "@/client/features/keywords/locations";
import { getIsoCountryCode } from "@/shared/keyword-locations";
import { LocationSelect } from "@/client/components/LocationSelect";
import type { ProjectMarket } from "@/client/features/projects/types";
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
import { SearchTargetingField } from "./SearchTargetingField";
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
import { useSaveConfigMutations } from "./useSaveConfigMutations";
@ -35,6 +36,45 @@ export function RankTrackingConfigModal({
onSaved,
onConfigCreated,
}: Props) {
const projectMarket = useProjectMarket(projectId);
if (!existingConfig && !projectMarket) {
return (
<Modal
maxWidth="max-w-lg"
onClose={onClose}
labelledBy="rank-config-modal-title"
>
<h2 id="rank-config-modal-title" className="sr-only">
Add Domain
</h2>
<div className="flex min-h-40 items-center justify-center">
<Loader2 className="size-5 animate-spin text-base-content/50" />
</div>
</Modal>
);
}
return (
<RankTrackingConfigModalContent
projectId={projectId}
existingConfig={existingConfig}
initialMarket={existingConfig ?? projectMarket!}
onClose={onClose}
onSaved={onSaved}
onConfigCreated={onConfigCreated}
/>
);
}
function RankTrackingConfigModalContent({
projectId,
existingConfig,
initialMarket,
onClose,
onSaved,
onConfigCreated,
}: Props & { initialMarket: ProjectMarket }) {
const isEdit = !!existingConfig;
const [step, setStep] = useState<"config" | "keywords">("config");
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
@ -42,11 +82,10 @@ export function RankTrackingConfigModal({
existingConfig?.devices ?? "mobile",
);
const [locationCode, setLocationCode] = useState(
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
existingConfig?.locationCode ?? initialMarket.locationCode,
);
const [languageCode, setLanguageCode] = useState(
existingConfig?.languageCode ??
getLanguageCode(existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE),
existingConfig?.languageCode ?? initialMarket.languageCode,
);
const languageOptions = useMemo(
() => getLanguageOptions(locationCode),

View File

@ -9,7 +9,6 @@ import {
buildKeywordResearchRequest,
keywordResearchQueryFn,
} from "@/client/features/keywords/hooks/useKeywordResearchData";
import { getLanguageCode } from "@/client/features/keywords/locations";
import { getBacklinksOverview } from "@/serverFunctions/backlinks";
import { getDomainOverview } from "@/serverFunctions/domain";
export type { SearchTab } from "./types";
@ -187,7 +186,6 @@ function getSearchTabQueryConfig(
if (tab.input.type === "domain") {
const input = tab.input;
const trimmedDomain = input.domain.trim();
const languageCode = getLanguageCode(input.locationCode);
return {
queryKey: [
@ -196,7 +194,6 @@ function getSearchTabQueryConfig(
trimmedDomain,
input.subdomains,
input.locationCode,
languageCode,
],
queryFn: () =>
getDomainOverview({
@ -205,7 +202,6 @@ function getSearchTabQueryConfig(
domain: trimmedDomain,
includeSubdomains: input.subdomains,
locationCode: input.locationCode,
languageCode,
},
}),
staleTime: 5 * 60_000,

View File

@ -14,13 +14,13 @@ export type DomainSearchTabInput = {
type: "domain";
domain: string;
subdomains: boolean;
locationCode: number;
locationCode?: number;
};
export type KeywordSearchTabInput = {
type: "keyword";
keyword: string;
locationCode: number;
locationCode?: number;
resultLimit: ResultLimit;
mode: KeywordMode;
clickstream: boolean;

View File

@ -9,7 +9,7 @@ type UseSearchTabNavigationArgs = {
navigateToInput: (input: SearchTabInput | null) => void;
};
function tabInputKey(input: SearchTabInput | null) {
export function tabInputKey(input: SearchTabInput | null) {
return input ? JSON.stringify(input) : "";
}

View File

@ -8,8 +8,8 @@ import {
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
domainSearchSchema,
} from "@/types/schemas/domain";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { getDomainRouteState } from "@/client/features/domain/domainRouteState";
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
const DEFAULT_DOMAIN_SEARCH = {
domain: "",
@ -17,7 +17,6 @@ const DEFAULT_DOMAIN_SEARCH = {
sort: "traffic",
order: undefined,
tab: "keywords",
loc: DEFAULT_LOCATION_CODE,
page: 1,
size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
include: "",
@ -52,7 +51,8 @@ function DomainOverviewRoute() {
const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const routeState = getDomainRouteState(search);
const projectMarket = useProjectMarket(projectId);
const routeState = getDomainRouteState(search, projectMarket);
return (
<DomainOverviewPage

View File

@ -1,5 +1,4 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
import {
isResultLimit,
@ -31,20 +30,17 @@ function KeywordResearchPageRoute() {
const search = Route.useSearch();
const {
q: keywordInput = "",
loc: rawLocationCode,
loc: locationCode,
kLimit: resultLimit = 150,
mode: keywordMode = "auto",
sort: sortField = "searchVolume",
order: sortDir = "desc",
} = search;
const locationCode = rawLocationCode ?? DEFAULT_LOCATION_CODE;
return (
<KeywordResearchPage
projectId={projectId}
keywordInput={keywordInput}
locationCode={locationCode}
hasExplicitLocationCode={search.loc != null}
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
keywordMode={normalizeKeywordMode(keywordMode)}
clickstream={search.cs ?? false}

View File

@ -9,7 +9,7 @@ import {
} from "@/server/lib/r2-cache";
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import type { KeywordResearchRow } from "@/types/keywords";
import type { ResearchKeywordsInput } from "@/types/schemas/keywords";
import type { ResolvedResearchKeywordsInput } from "@/types/schemas/keywords";
import { z } from "zod";
import { getKeywordDataProvider } from "@/shared/keyword-locations";
import { type EnrichedKeyword, normalizeKeyword } from "./helpers";
@ -93,7 +93,7 @@ const CACHE_VERSION = 3;
async function fetchRowsFromSource(
source: KeywordSource,
input: ResearchKeywordsInput,
input: ResolvedResearchKeywordsInput,
seedKeyword: string,
billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
@ -113,7 +113,7 @@ async function fetchRowsFromSource(
}
async function fetchAutoRows(
input: ResearchKeywordsInput,
input: ResolvedResearchKeywordsInput,
seedKeyword: string,
billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
@ -175,7 +175,7 @@ async function fetchAutoRows(
}
async function fetchGoogleAdsRows(
input: ResearchKeywordsInput,
input: ResolvedResearchKeywordsInput,
seedKeyword: string,
billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
@ -211,7 +211,7 @@ async function fetchGoogleAdsRows(
async function fetchManualRows(
mode: Exclude<KeywordMode, "auto">,
input: ResearchKeywordsInput,
input: ResolvedResearchKeywordsInput,
seedKeyword: string,
billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
@ -242,7 +242,7 @@ async function fetchManualRows(
}
async function buildResearchCacheKey(
input: ResearchKeywordsInput,
input: ResolvedResearchKeywordsInput,
normalizedKeywords: string[],
mode: KeywordMode,
billingCustomer: BillingCustomerContext,
@ -261,7 +261,10 @@ async function buildResearchCacheKey(
});
}
function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
function persistRows(
input: ResolvedResearchKeywordsInput,
rows: EnrichedKeyword[],
) {
void Promise.all(
rows.map((row) =>
KeywordResearchRepository.upsertKeywordMetric({
@ -283,7 +286,7 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
}
export async function research(
input: ResearchKeywordsInput,
input: ResolvedResearchKeywordsInput,
billingCustomer: BillingCustomerContext,
creditFeature?: CreditFeature,
): Promise<ResearchResult> {
@ -300,7 +303,7 @@ export async function research(
// Labs source modes and clickstream refinement don't exist for
// Google-Ads-served countries; collapse both so equivalent requests share
// one cache entry.
const effectiveInput: ResearchKeywordsInput =
const effectiveInput: ResolvedResearchKeywordsInput =
provider === "google_ads"
? { ...input, mode: "auto", clickstream: false }
: input;

View File

@ -5,7 +5,7 @@ import type {
ExportSavedKeywordsInput,
GetSavedKeywordsInput,
RemoveSavedKeywordsInput,
SaveKeywordsInput,
ResolvedSaveKeywordsInput,
UpdateSavedKeywordTagInput,
UpdateSavedKeywordTagsInput,
} from "@/types/schemas/keywords";
@ -31,7 +31,7 @@ function parseMonthlySearches(payload: string | null): MonthlySearch[] {
return result.success ? result.data : [];
}
export async function saveKeywords(input: SaveKeywordsInput) {
export async function saveKeywords(input: ResolvedSaveKeywordsInput) {
const normalizedKeywords = [
...new Set(
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),

View File

@ -61,11 +61,13 @@ async function createProject(
organizationId: string,
name: string,
domain?: string,
// Omitted keeps the column defaults.
market?: { locationCode: number; languageCode: string },
) {
const id = crypto.randomUUID();
const [row] = await db
.insert(projects)
.values({ id, organizationId, name, domain })
.values({ id, organizationId, name, domain, ...market })
.returning();
return row;
}
@ -73,15 +75,48 @@ async function createProject(
async function updateProject(
projectId: string,
organizationId: string,
input: { name: string; domain?: string },
input: {
name: string;
domain?: string;
// Omitted leaves the market columns untouched.
market?: { locationCode: number; languageCode: string };
},
) {
const [row] = await db
.update(projects)
.set({ name: input.name, domain: input.domain ?? null })
.set({ name: input.name, domain: input.domain ?? null, ...input.market })
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
)
.returning();
if (!row) {
throw new AppError("NOT_FOUND");
}
return row;
}
// Writes only the market columns. Onboarding sets the project's market before
// the user has named the project or picked a domain, so it must not go through
// updateProject, whose `domain: input.domain ?? null` would clear the domain.
async function updateProjectMarket(
projectId: string,
organizationId: string,
market: { locationCode: number; languageCode: string },
) {
const [row] = await db
.update(projects)
.set(market)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, organizationId),
isNull(projects.archivedAt),
),
)
.returning();
@ -162,6 +197,7 @@ export const ProjectRepository = {
getProjectById,
createProject,
updateProject,
updateProjectMarket,
tryCreateDefaultProject,
archiveProject,
restoreProject,

View File

@ -6,6 +6,7 @@ import {
listProjects,
listProjectsEnsuringOne,
restoreProject,
setProjectMarket,
updateProject,
} from "@/server/features/projects/services/projects";
@ -14,6 +15,7 @@ export const ProjectService = {
listProjectsEnsuringOne,
createProject,
updateProject,
setProjectMarket,
archiveProject,
restoreProject,
listArchivedProjects,

View File

@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
archiveProject: vi.fn(),
restoreProject: vi.fn(),
countProjects: vi.fn(),
updateProjectMarket: vi.fn(),
getProjectForOrganization: vi.fn(),
listProjects: vi.fn(),
listArchivedProjects: vi.fn(),
@ -89,9 +90,40 @@ describe("project service", () => {
"org_1",
"Acme",
"acme.com",
undefined,
);
});
it("derives the native language when only the location is given", async () => {
mocks.createProject.mockResolvedValue(namedProject);
const { createProject } = await import("./projects");
await createProject("org_1", {
name: "Acme",
domain: "acme.com",
locationCode: 2704,
});
expect(mocks.createProject).toHaveBeenCalledWith(
"org_1",
"Acme",
"acme.com",
{ locationCode: 2704, languageCode: "vi" },
);
});
it("rejects a language DataForSEO does not serve for the location", async () => {
const { createProject } = await import("./projects");
await expect(
createProject("org_1", {
name: "Acme",
locationCode: 2840,
languageCode: "vi",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.createProject).not.toHaveBeenCalled();
});
it("maps the reserved Default conflict to a friendly CONFLICT", async () => {
mocks.createProject.mockRejectedValue(
new Error(
@ -107,6 +139,37 @@ describe("project service", () => {
});
describe("updateProject", () => {
it("leaves the market columns untouched when neither half is given", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");
await updateProject("org_1", { projectId: "project_acme", name: "Acme" });
expect(mocks.updateProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
expect.objectContaining({ market: undefined }),
);
});
it("snaps the language on a location-only change without reading the stored row", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");
await updateProject("org_1", {
projectId: "project_acme",
name: "Acme",
locationCode: 2276,
});
expect(mocks.getProjectForOrganization).not.toHaveBeenCalled();
expect(mocks.updateProject).toHaveBeenCalledWith(
"project_acme",
"org_1",
expect.objectContaining({
market: { locationCode: 2276, languageCode: "de" },
}),
);
});
it("returns the updated project", async () => {
mocks.updateProject.mockResolvedValue(namedProject);
const { updateProject } = await import("./projects");
@ -145,6 +208,45 @@ describe("project service", () => {
});
});
describe("setProjectMarket", () => {
it("writes only the market columns, leaving name and domain untouched", async () => {
// Onboarding sets the market before the project is named or given a
// domain; going through updateProject would clear the domain.
mocks.updateProjectMarket.mockResolvedValue({
...namedProject,
locationCode: 2704,
languageCode: "vi",
});
const { setProjectMarket } = await import("./projects");
await setProjectMarket("org_1", {
projectId: "project_acme",
locationCode: 2704,
languageCode: "vi",
});
expect(mocks.updateProjectMarket).toHaveBeenCalledWith(
"project_acme",
"org_1",
{ locationCode: 2704, languageCode: "vi" },
);
expect(mocks.updateProject).not.toHaveBeenCalled();
});
it("rejects a language the location does not serve before any write", async () => {
const { setProjectMarket } = await import("./projects");
await expect(
setProjectMarket("org_1", {
projectId: "project_acme",
locationCode: 2840,
languageCode: "vi",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.updateProjectMarket).not.toHaveBeenCalled();
});
});
describe("archiveProject", () => {
it("refuses to archive the org's only project", async () => {
mocks.countProjects.mockResolvedValue(1);

View File

@ -2,25 +2,50 @@ import type {
ArchiveProjectInput,
CreateProjectInput,
RestoreProjectInput,
SetProjectMarketInput,
UpdateProjectInput,
} from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { AppError } from "@/server/lib/errors";
import { assertLanguageForLocation } from "@/server/lib/market";
import { getLanguageCode } from "@/shared/keyword-locations";
function mapProject(project: {
id: string;
name: string;
domain: string | null;
locationCode: number;
languageCode: string;
createdAt: string;
}) {
return {
id: project.id,
name: project.name,
domain: project.domain,
// Default market for the project's data calls (MCP tools and the web UI
// fall back to these when a call omits locationCode/languageCode).
locationCode: project.locationCode,
languageCode: project.languageCode,
createdAt: project.createdAt,
};
}
/**
* Resolves a market input into the columns to write. A location with no
* language snaps to that location's native language; the schemas forbid the
* reverse, so the pair is always resolvable without reading the stored row.
*/
function resolveMarketInput(input: {
locationCode?: number;
languageCode?: string;
}): { locationCode: number; languageCode: string } | undefined {
if (input.locationCode == null) return undefined;
const locationCode = input.locationCode;
const languageCode = input.languageCode ?? getLanguageCode(locationCode);
assertLanguageForLocation(locationCode, languageCode);
return { locationCode, languageCode };
}
// The projects table's only unique index guards the auto-created ("Default",
// null) singleton. A UNIQUE violation while writing exactly that name/domain
// therefore means one already exists — gating on the input (not just the error
@ -67,6 +92,7 @@ export async function createProject(
organizationId,
input.name,
input.domain,
resolveMarketInput(input),
);
return mapProject(row);
} catch (error) {
@ -85,7 +111,11 @@ export async function updateProject(
const row = await ProjectRepository.updateProject(
input.projectId,
organizationId,
{ name: input.name, domain: input.domain },
{
name: input.name,
domain: input.domain,
market: resolveMarketInput(input),
},
);
return mapProject(row);
} catch (error) {
@ -96,6 +126,24 @@ export async function updateProject(
}
}
/**
* Sets a project's default market on its own, for surfaces that only ask for
* the market (onboarding). Writing just these two columns keeps the write from
* echoing a name/domain the caller never edited.
*/
export async function setProjectMarket(
organizationId: string,
input: SetProjectMarketInput,
) {
assertLanguageForLocation(input.locationCode, input.languageCode);
const row = await ProjectRepository.updateProjectMarket(
input.projectId,
organizationId,
{ locationCode: input.locationCode, languageCode: input.languageCode },
);
return mapProject(row);
}
export async function archiveProject(
organizationId: string,
input: ArchiveProjectInput,

View File

@ -29,6 +29,7 @@ const archivedConfig = {
const baseInput = {
projectId: "project_1",
projectMarket: { locationCode: 2704, languageCode: "vi" },
domain: "acme.com",
locationCode: 2840,
languageCode: "es",
@ -154,4 +155,41 @@ describe("RankTrackingService.createConfig", () => {
}),
);
});
it("uses the project's market when location and language are omitted", async () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
const { RankTrackingService } = await import("./RankTrackingService");
await RankTrackingService.createConfig({
projectId: "project_1",
projectMarket: { locationCode: 2704, languageCode: "vi" },
domain: "acme.com",
serpDepth: 40,
});
expect(mocks.createConfig).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2704, languageCode: "vi" }),
);
});
it("snaps the language when only location overrides the project market", async () => {
mocks.getConfigByProjectDomainLocation.mockResolvedValue(null);
mocks.getConfigsForProject.mockResolvedValue([]);
mocks.createConfig.mockResolvedValue(undefined);
const { RankTrackingService } = await import("./RankTrackingService");
await RankTrackingService.createConfig({
projectId: "project_1",
projectMarket: { locationCode: 2704, languageCode: "vi" },
domain: "acme.com",
locationCode: 2276,
serpDepth: 40,
});
expect(mocks.createConfig).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2276, languageCode: "de" }),
);
});
});

View File

@ -22,6 +22,7 @@ import {
MAX_KEYWORDS_PER_CONFIG,
MAX_CONFIGS_PER_PROJECT,
} from "@/shared/rank-tracking";
import { resolveMarket } from "@/shared/keyword-locations";
// ---------------------------------------------------------------------------
// Config
@ -29,6 +30,7 @@ import {
async function createConfig(input: {
projectId: string;
projectMarket: { locationCode: number; languageCode: string };
domain: string;
locationCode?: number;
languageCode?: string;
@ -39,7 +41,10 @@ async function createConfig(input: {
}) {
const normalizedDomain = normalizeDomain(input.domain);
const locationCode = input.locationCode ?? 2840;
const { locationCode, languageCode } = resolveMarket(
input,
input.projectMarket,
);
const scheduleInterval = input.scheduleInterval ?? "weekly";
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
? computeNextCheckAt(scheduleInterval)
@ -82,7 +87,7 @@ async function createConfig(input: {
if (existing) {
await RankTrackingRepository.updateConfig(existing.id, input.projectId, {
isActive: true,
languageCode: input.languageCode ?? "en",
languageCode,
devices: input.devices ?? "both",
serpDepth: input.serpDepth,
scheduleInterval,
@ -102,7 +107,7 @@ async function createConfig(input: {
projectId: input.projectId,
domain: normalizedDomain,
locationCode,
languageCode: input.languageCode ?? "en",
languageCode,
locationName,
devices: input.devices ?? "both",
serpDepth: input.serpDepth,

43
src/server/lib/market.ts Normal file
View File

@ -0,0 +1,43 @@
import { AppError } from "@/server/lib/errors";
import {
DEFAULT_LOCATION_CODE,
getKeywordDataProvider,
getLanguageOptions,
isLanguageServedForLocation,
} from "@/shared/keyword-locations";
/**
* Guards Labs-backed tools (domain analytics) against locations we serve
* from Google Ads keyword data only.
*/
export function assertLabsLocationCode(locationCode: number | undefined) {
if (locationCode != null && getKeywordDataProvider(locationCode) !== "labs") {
throw new AppError(
"VALIDATION_ERROR",
"Domain analytics is not available for this country. Keyword research and rank tracking work; domain-level data is limited to DataForSEO Labs locations.",
);
}
}
/**
* Guards Labs-backed callers against a language DataForSEO doesn't serve for
* the chosen location. A mismatched pair (e.g. language_code="ru" for the
* United States) is otherwise rejected as an opaque *charged* "Invalid Field:
* 'language_code'." task failure, so validate the pair first (cost 0).
*/
export function assertLanguageForLocation(
locationCode: number | undefined,
languageCode: string | undefined,
) {
if (languageCode == null) return;
const resolvedLocation = locationCode ?? DEFAULT_LOCATION_CODE;
if (isLanguageServedForLocation(resolvedLocation, languageCode)) return;
throw new AppError(
"VALIDATION_ERROR",
`Language '${languageCode}' is not available for this location. Available: ${getLanguageOptions(
resolvedLocation,
)
.map((option) => option.code)
.join(", ")}.`,
);
}

View File

@ -46,6 +46,8 @@ describe("withMcpProjectAuth", () => {
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_123",
name: "Test",
locationCode: 2840,
languageCode: "en",
});
});
@ -64,7 +66,7 @@ describe("withMcpProjectAuth", () => {
);
});
it("passes auth, baseUrl, and billing context to the wrapped handler", async () => {
it("passes auth, baseUrl, billing, and project context to the wrapped handler", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockReturnValue("ok");
@ -90,6 +92,12 @@ describe("withMcpProjectAuth", () => {
organizationId: "org_123",
projectId: "project_123",
},
project: {
id: "project_123",
name: "Test",
locationCode: 2840,
languageCode: "en",
},
},
);
});

View File

@ -28,6 +28,9 @@ async function requireProjectAccess(extra: ToolExtra, projectId: string) {
auth,
baseUrl,
billing: buildBillingCustomer(auth, projectId),
// The row is already fetched for the auth gate; exposing it lets tools
// fall back to the project's default market without another query.
project,
};
}

View File

@ -1,13 +1,7 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import {
getKeywordDataProvider,
getLanguageOptions,
isSupportedLanguageCode,
} from "@/shared/keyword-locations";
import { isSupportedLanguageCode } from "@/shared/keyword-locations";
export const DEFAULT_LOCATION_CODE = 2840;
export const DEFAULT_LANGUAGE_CODE = "en";
export const projectIdSchema = z
.string()
@ -21,52 +15,15 @@ export const locationCodeSchema = z
.int()
.positive()
.describe(
"DataForSEO location code. Defaults to 2840 (United States). See dataforseo.com/help-center/locations. Some countries (e.g. Iceland, 2352) are served from Google Ads data: keyword volume/CPC/trends work, but keyword difficulty, search intent, and domain analytics are unavailable.",
"DataForSEO location code. Defaults to the project's default market (see list_projects; editable in project settings). See dataforseo.com/help-center/locations. Some countries (e.g. Iceland, 2352) are served from Google Ads data: keyword volume/CPC/trends work, but keyword difficulty, search intent, and domain analytics are unavailable.",
);
/**
* Guards Labs-backed tools (domain analytics) against locations we serve
* from Google Ads keyword data only.
*/
export function assertLabsLocationCode(locationCode: number | undefined) {
if (locationCode != null && getKeywordDataProvider(locationCode) !== "labs") {
throw new AppError(
"VALIDATION_ERROR",
"Domain analytics is not available for this country. Keyword research and rank tracking work; domain-level data is limited to DataForSEO Labs locations.",
);
}
}
/**
* Guards Labs-backed tools against a language DataForSEO doesn't serve for the
* chosen location. A mismatched pair (e.g. language_code="ru" for the United
* States) is otherwise rejected as an opaque *charged* "Invalid Field:
* 'language_code'." task failure, so validate the pair first (cost 0). Only
* Labs locations have authoritative per-location language lists; Google Ads
* locations are left to the metering safety net.
*/
export function assertLanguageForLocation(
locationCode: number | undefined,
languageCode: string | undefined,
) {
if (languageCode == null) return;
const resolvedLocation = locationCode ?? DEFAULT_LOCATION_CODE;
if (getKeywordDataProvider(resolvedLocation) !== "labs") return;
const options = getLanguageOptions(resolvedLocation);
if (!options.some((option) => option.code === languageCode)) {
throw new AppError(
"VALIDATION_ERROR",
`Language '${languageCode}' is not available for this location. Available: ${options
.map((option) => option.code)
.join(", ")}.`,
);
}
}
export const languageCodeSchema = z
.string()
.refine(isSupportedLanguageCode, {
message:
"Unsupported language code. Use a supported code such as 'en', 'es', 'de', or 'fr'.",
})
.describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'.");
.describe(
"Language code (e.g. 'en', 'es', 'vi'). Defaults to the project's default market language (see list_projects).",
);

View File

@ -62,7 +62,11 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
vi.resetModules();
mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
});
it("serves Iceland from adsSearchVolume without KD/intent", async () => {

View File

@ -0,0 +1,114 @@
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
// Market resolution for get_ranked_keywords: the explicit country selector and
// the project's default-market fallback (projects.locationCode/languageCode).
// find_serp_competitors resolves through the same resolveMarketSelector.
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
getProjectForOrganization: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/dataforseo", () => ({
createDataforseoClient: mocks.createDataforseoClient,
fetchKeywordMetricsForList: vi.fn(),
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
const authContext = {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
const toolExtra: ToolExtra = {
signal: new AbortController().signal,
requestId: 1,
sendNotification: vi.fn(),
sendRequest: vi.fn(),
authInfo: {
token: "token",
clientId: "client_123",
scopes: ["mcp"],
resource: new URL("https://open-seo.test/mcp"),
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
} satisfies AuthInfo,
};
function setProject(market: { locationCode: number; languageCode: string }) {
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
name: "Test",
domain: null,
createdAt: "2026-01-01T00:00:00.000Z",
...market,
});
}
async function runRankedKeywords(args: { market?: { country: "US" } }) {
const rankedKeywords = vi.fn().mockResolvedValue({
items: [],
totalCount: 0,
});
mocks.createDataforseoClient.mockReturnValue({
domain: { rankedKeywords },
});
const { getRankedKeywordsTool } = await import("./dataforseo-research-tools");
await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com", ...args },
toolExtra,
);
return rankedKeywords;
}
describe("market resolution for Labs tools", () => {
beforeEach(() => {
vi.resetModules();
mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset();
setProject({ locationCode: 2840, languageCode: "en" });
});
it("keeps the US when market is explicit even for a non-US project", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({
market: { country: "US" },
});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2840, languageCode: "en" }),
);
});
it("follows the project's default market when the market object is omitted", async () => {
setProject({ locationCode: 2704, languageCode: "vi" });
const rankedKeywords = await runRankedKeywords({});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2704, languageCode: "vi" }),
);
});
it("falls back to the US when the project market is not Labs-served", async () => {
// Iceland (2352) is served from Google Ads data; the Labs-only market
// tools must not inherit it.
setProject({ locationCode: 2352, languageCode: "en" });
const rankedKeywords = await runRankedKeywords({});
expect(rankedKeywords).toHaveBeenCalledWith(
expect.objectContaining({ locationCode: 2840, languageCode: "en" }),
);
});
});

View File

@ -64,12 +64,18 @@ function textOf(result: {
return first?.type === "text" ? (first.text ?? "") : "";
}
const usProjectRow = {
id: "project_1",
locationCode: 2840,
languageCode: "en",
};
describe("DataForSEO research MCP tools", () => {
beforeEach(() => {
vi.resetModules();
mocks.createDataforseoClient.mockReset();
mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.getProjectForOrganization.mockResolvedValue(usProjectRow);
});
it("searches local businesses without running rankings or Q&A", async () => {

View File

@ -17,10 +17,10 @@ import {
readPath,
type McpTableColumn,
} from "@/server/mcp/table";
import { resolveLabsMarket, resolveMarket } from "@/shared/keyword-locations";
import { assertLanguageForLocation } from "@/server/lib/market";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLanguageForLocation,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
@ -46,10 +46,14 @@ const marketSchema = z
country: z
.enum(["US", "USA", "United States", "United States of America"])
.optional()
.describe("Country selector. Only the United States is supported."),
.describe(
"Country selector. Only the United States can be selected explicitly.",
),
})
.optional()
.describe("Optional United States market object. Defaults to United States.");
.describe(
"Optional market object. Omitted = the project's default market (United States unless the project overrides it).",
);
const nearSchema = z
.object({
@ -344,10 +348,20 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
function resolveMarketLocationCode(_market: Market | undefined): number {
// The Zod enum on market.country already restricts values to United States
// variants, so no other country can reach this code path.
return DEFAULT_LOCATION_CODE;
/**
* Resolves the market selector to a Labs location + language. An explicit
* country wins; omitted inherits the project's default via resolveLabsMarket,
* which keeps these Labs-only tools off an Ads-served project market.
*/
function resolveMarketSelector(
market: Market | undefined,
project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } {
if (market?.country != null) {
// The Zod enum already restricts explicit values to United States variants.
return { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" };
}
return resolveLabsMarket({}, project);
}
function formatCoordinate(value: number): string {
@ -594,10 +608,11 @@ export const getRankedKeywordsTool = {
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
const client = createDataforseoClient(context.billing);
const targetIsPage = /^https?:\/\//.test(args.target);
const market = resolveMarketSelector(args.market, context.project);
const keywords = await client.domain.rankedKeywords({
target: args.target,
locationCode: resolveMarketLocationCode(args.market),
languageCode: DEFAULT_LANGUAGE_CODE,
locationCode: market.locationCode,
languageCode: market.languageCode,
limit: args.limit ?? 50,
offset: args.offset,
orderBy: sortOrderByRankedMode(args.sortBy),
@ -693,7 +708,7 @@ export const getLocalSerpResultsTool = {
const results = await client.serp.local({
keyword: args.keyword,
locationCoordinate: formatLocalSerpCoordinate(args.near),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
languageCode: args.languageCode ?? context.project.languageCode,
searchType: args.searchType ?? "maps",
device: args.device ?? "desktop",
depth: args.depth ?? 20,
@ -736,7 +751,7 @@ export const getGoogleBusinessQuestionsTool = {
const questions = await client.business.questionsAnswers({
keyword: args.keyword,
locationCoordinate: formatQuestionsAnswersCoordinate(args.near),
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
languageCode: args.languageCode ?? context.project.languageCode,
depth: args.depth ?? 20,
});
@ -773,10 +788,11 @@ export const findSerpCompetitorsTool = {
handler: withMcpProjectAuth(
async (args: FindSerpCompetitorsArgs, context) => {
const client = createDataforseoClient(context.billing);
const market = resolveMarketSelector(args.market, context.project);
const competitors = await client.labs.serpCompetitors({
keywords: args.keywords,
locationCode: resolveMarketLocationCode(args.market),
languageCode: DEFAULT_LANGUAGE_CODE,
locationCode: market.locationCode,
languageCode: market.languageCode,
itemTypes: args.resultTypes ?? ["organic", "local_pack"],
includeSubdomains: args.includeSubdomains,
limit: args.limit ?? 50,
@ -829,10 +845,11 @@ export const getKeywordMetricsTool = {
},
},
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
assertLanguageForLocation(args.locationCode, args.languageCode);
const { locationCode, languageCode } = resolveMarket(args, context.project);
// Assert against the RESOLVED pair: an explicit language with an omitted
// location must validate against the project's default location.
assertLanguageForLocation(locationCode, languageCode);
const client = createDataforseoClient(context.billing);
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
const metrics = await fetchKeywordMetricsForList(client, {
keywords: args.keywords,
locationCode,

View File

@ -7,16 +7,17 @@ import {
optionalMetaOutputSchema,
} from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveLabsMarket } from "@/shared/keyword-locations";
import {
formatMcpTable,
readPath,
type McpTableColumn,
} from "@/server/mcp/table";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLabsLocationCode,
assertLanguageForLocation,
} from "@/server/lib/market";
import {
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
@ -59,13 +60,17 @@ export const getDomainKeywordSuggestionsTool = {
},
},
handler: withMcpProjectAuth(async (args: Args, context) => {
assertLabsLocationCode(args.locationCode);
assertLanguageForLocation(args.locationCode, args.languageCode);
const { locationCode, languageCode } = resolveLabsMarket(
args,
context.project,
);
assertLabsLocationCode(locationCode);
assertLanguageForLocation(locationCode, languageCode);
const keywords = await DomainService.getSuggestedKeywords(
{
domain: args.domain,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
locationCode,
languageCode,
organizationId: context.auth.organizationId,
projectId: args.projectId,
},

View File

@ -4,11 +4,12 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveLabsMarket } from "@/shared/keyword-locations";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLabsLocationCode,
assertLanguageForLocation,
} from "@/server/lib/market";
import {
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
@ -52,15 +53,19 @@ export const getDomainOverviewTool = {
},
},
handler: withMcpProjectAuth(async (args: Args, context) => {
assertLabsLocationCode(args.locationCode);
assertLanguageForLocation(args.locationCode, args.languageCode);
const { locationCode, languageCode } = resolveLabsMarket(
args,
context.project,
);
assertLabsLocationCode(locationCode);
assertLanguageForLocation(locationCode, languageCode);
const result = await DomainService.getOverview(
{
projectId: args.projectId,
domain: args.domain,
includeSubdomains: args.includeSubdomains,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
locationCode,
languageCode,
},
context.billing,
);

View File

@ -4,10 +4,9 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveMarket } from "@/shared/keyword-locations";
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
@ -95,14 +94,12 @@ export const getSerpResultsTool = {
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const client = createDataforseoClient(context.billing);
const results = await Promise.all(
args.queries.map(async (q) => {
try {
const items = await client.serp.live({
keyword: q.keyword,
locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE,
...resolveMarket(q, context.project),
});
// Trim noise — return only essentials per item.
const trimmed = items.slice(0, 20).map((item) => ({

View File

@ -13,7 +13,7 @@ export const listProjectsTool = {
config: {
title: "List projects",
description:
"Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
"Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain, locationCode, languageCode}; pass the `id` value as `projectId`. locationCode/languageCode are the project's default market — tools fall back to them when a call omits location/language args.",
inputSchema: {} as Record<string, never>,
outputSchema: {
projects: z.array(
@ -22,6 +22,8 @@ export const listProjectsTool = {
id: z.string(),
name: z.string(),
domain: z.string().nullable().optional(),
locationCode: z.number(),
languageCode: z.string(),
url: z.string(),
})
.passthrough(),
@ -41,7 +43,8 @@ export const listProjectsTool = {
projects.length === 0
? ["No projects yet. Create one in the dashboard."]
: projects.map(
(p) => `- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}`,
(p) =>
`- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""} market:${p.locationCode}/${p.languageCode}`,
);
return mcpResponse({
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
@ -54,6 +57,8 @@ export const listProjectsTool = {
id: p.id,
name: p.name,
domain: p.domain,
locationCode: p.locationCode,
languageCode: p.languageCode,
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
})),
},

View File

@ -99,7 +99,11 @@ const backlinkPage = {
beforeEach(() => {
mocks.getProjectForOrganization.mockReset();
mocks.profileBacklinksPage.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_123" });
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_123",
locationCode: 2840,
languageCode: "en",
});
});
describe("DataForSEO research tool output schemas", () => {

View File

@ -7,11 +7,10 @@ import {
optionalMetaOutputSchema,
} from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveMarket } from "@/shared/keyword-locations";
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
import { assertLanguageForLocation } from "@/server/lib/market";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
assertLanguageForLocation,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
@ -108,13 +107,17 @@ export const researchKeywordsTool = {
const results = await Promise.all(
args.seeds.map(async (item) => {
try {
assertLanguageForLocation(item.locationCode, item.languageCode);
const { locationCode, languageCode } = resolveMarket(
item,
context.project,
);
assertLanguageForLocation(locationCode, languageCode);
const data = await KeywordResearchService.research(
{
projectId: args.projectId,
keywords: [item.seed],
locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE,
locationCode,
languageCode,
resultLimit: args.resultLimit ?? 150,
mode: "auto",
clickstream: args.includeClickstreamData ?? false,

View File

@ -4,9 +4,8 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { resolveMarket } from "@/shared/keyword-locations";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
@ -66,8 +65,7 @@ export const saveKeywordsTool = {
throw new Error("Replacement tags are required when tagMode is replace.");
}
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
const { locationCode, languageCode } = resolveMarket(args, context.project);
await KeywordResearchService.saveKeywords({
projectId: args.projectId,

View File

@ -51,7 +51,11 @@ describe("saved keyword MCP tools", () => {
beforeEach(() => {
vi.resetModules();
mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
mocks.getSavedKeywords.mockReset();
mocks.saveKeywords.mockReset();
});

View File

@ -76,7 +76,11 @@ const toolExtra: ToolExtra = {
describe("search console MCP tools", () => {
beforeEach(() => {
mocks.getProjectForOrganization.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
mocks.isHostedServerAuthMode.mockReset();
mocks.isHostedServerAuthMode.mockResolvedValue(true);
mocks.hasSelfHostedGscConfig.mockReset();

View File

@ -90,7 +90,11 @@ describe("MCP tool text output (service-backed tools)", () => {
beforeEach(() => {
vi.resetModules();
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
});
it("research_keywords renders every keyword row in the text table", async () => {

View File

@ -7,6 +7,7 @@ import {
domainPagesPageRequestSchema,
} from "@/types/schemas/domain";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { resolveLabsMarket } from "@/shared/keyword-locations";
function shouldUseDomainE2eFixtures() {
return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1";
@ -20,18 +21,17 @@ export const getDomainOverview = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(domainOverviewSchema)
.handler(async ({ data, context }) => {
const input = {
...data,
...resolveLabsMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures();
return fixtures.getFixtureOverview(data.domain);
return fixtures.getFixtureOverview(input.domain);
}
return DomainService.getOverview(
{
...data,
projectId: context.projectId,
},
context,
);
return DomainService.getOverview(input, context);
});
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
@ -41,6 +41,7 @@ export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
DomainService.getSuggestedKeywords(
{
...data,
...resolveLabsMarket(data, context.project),
organizationId: context.organizationId,
projectId: context.projectId,
},
@ -52,34 +53,32 @@ export const getDomainKeywordsPage = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(domainKeywordsPageRequestSchema)
.handler(async ({ data, context }) => {
const input = {
...data,
...resolveLabsMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures();
return fixtures.getFixtureKeywordsPage(data);
return fixtures.getFixtureKeywordsPage(input);
}
return DomainService.getKeywordsPage(
{
...data,
projectId: context.projectId,
},
context,
);
return DomainService.getKeywordsPage(input, context);
});
export const getDomainPagesPage = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(domainPagesPageRequestSchema)
.handler(async ({ data, context }) => {
const input = {
...data,
...resolveLabsMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseDomainE2eFixtures()) {
const fixtures = await getDomainE2eFixtures();
return fixtures.getFixturePagesPage(data);
return fixtures.getFixturePagesPage(input);
}
return DomainService.getPagesPage(
{
...data,
projectId: context.projectId,
},
context,
);
return DomainService.getPagesPage(input, context);
});

View File

@ -13,6 +13,7 @@ import {
} from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { resolveMarket } from "@/shared/keyword-locations";
function shouldUseKeywordE2eFixtures() {
return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1";
@ -26,18 +27,17 @@ export const researchKeywords = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(researchKeywordsSchema)
.handler(async ({ data, context }) => {
const input = {
...data,
...resolveMarket(data, context.project),
projectId: context.projectId,
};
if (shouldUseKeywordE2eFixtures()) {
const fixtures = await getKeywordE2eFixtures();
return fixtures.getKeywordResearchFixture(data);
return fixtures.getKeywordResearchFixture(input);
}
return KeywordResearchService.research(
{
...data,
projectId: context.projectId,
},
context,
);
return KeywordResearchService.research(input, context);
});
export const saveKeywords = createServerFn({ method: "POST" })
@ -46,6 +46,7 @@ export const saveKeywords = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
return KeywordResearchService.saveKeywords({
...data,
...resolveMarket(data, context.project),
projectId: context.projectId,
});
});
@ -126,6 +127,7 @@ export const getSerpAnalysis = createServerFn({ method: "POST" })
KeywordResearchService.getSerpAnalysis(
{
...data,
...resolveMarket(data, context.project),
projectId: context.projectId,
},
context,

View File

@ -8,6 +8,7 @@ import {
archiveProjectSchema,
createProjectSchema,
restoreProjectSchema,
setProjectMarketSchema,
updateProjectSchema,
} from "@/types/schemas/projects";
import { z } from "zod";
@ -34,6 +35,13 @@ export const updateProject = createServerFn({ method: "POST" })
ProjectService.updateProject(context.organizationId, data),
);
export const setProjectMarket = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(setProjectMarketSchema)
.handler(async ({ data, context }) =>
ProjectService.setProjectMarket(context.organizationId, data),
);
export const archiveProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(archiveProjectSchema)

View File

@ -77,6 +77,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
const result = await RankTrackingService.createConfig({
projectId: context.projectId,
projectMarket: context.project,
domain: data.domain,
locationCode: data.locationCode,
languageCode: data.languageCode,

View File

@ -9,6 +9,8 @@ import {
isLabsLocationCode,
isSupportedLanguageCode,
isSupportedLocationCode,
resolveLabsMarket,
resolveMarket,
} from "./keyword-locations";
describe("keyword locations", () => {
@ -94,3 +96,76 @@ describe("formatLocationLabel", () => {
);
});
});
describe("resolveMarket", () => {
const vietnamProject = { locationCode: 2704, languageCode: "vi" };
it("falls back to the project's pair when nothing is supplied", () => {
expect(resolveMarket({}, vietnamProject)).toEqual({
locationCode: 2704,
languageCode: "vi",
});
});
it("snaps the language to a location override instead of borrowing the project's", () => {
// A Vietnam project querying Germany must not send Vietnamese.
expect(resolveMarket({ locationCode: 2276 }, vietnamProject)).toEqual({
locationCode: 2276,
languageCode: "de",
});
});
it("keeps the project language when the override matches the project location", () => {
const spanishUs = { locationCode: 2840, languageCode: "es" };
expect(resolveMarket({ locationCode: 2840 }, spanishUs)).toEqual({
locationCode: 2840,
languageCode: "es",
});
});
it("applies an explicit language to the project's location", () => {
expect(resolveMarket({ languageCode: "en" }, vietnamProject)).toEqual({
locationCode: 2704,
languageCode: "en",
});
});
it("uses both overrides verbatim", () => {
expect(
resolveMarket({ locationCode: 2276, languageCode: "en" }, vietnamProject),
).toEqual({ locationCode: 2276, languageCode: "en" });
});
});
describe("resolveLabsMarket", () => {
it("inherits a Labs-served project market like resolveMarket does", () => {
expect(
resolveLabsMarket({}, { locationCode: 2704, languageCode: "vi" }),
).toEqual({ locationCode: 2704, languageCode: "vi" });
});
it("falls back to the US when the project market is Google-Ads-served", () => {
// Iceland has no Labs data. The caller never picked it, so a Labs-only
// tool must not fail on it.
expect(
resolveLabsMarket({}, { locationCode: 2352, languageCode: "en" }),
).toEqual({ locationCode: 2840, languageCode: "en" });
});
it("falls back to the US when the project pair is not served", () => {
// Concurrent half-updates can leave a location/language pair Labs rejects;
// sending it would spend credits on a task that always fails.
expect(
resolveLabsMarket({}, { locationCode: 2276, languageCode: "vi" }),
).toEqual({ locationCode: 2840, languageCode: "en" });
});
it("leaves an explicit location alone so the caller's assert can reject it", () => {
expect(
resolveLabsMarket(
{ locationCode: 2352 },
{ locationCode: 2704, languageCode: "vi" },
),
).toMatchObject({ locationCode: 2352 });
});
});

View File

@ -697,6 +697,66 @@ export function getLanguageCode(locationCode: number): string {
return LOCATION_LANGUAGE[locationCode] ?? "en";
}
/**
* Resolves a request's market against the project's default. The pair is
* resolved together: overriding only the location snaps the language to that
* location's default language, because the project's language was chosen for
* the project's own location and may not be valid or sensible for the
* override (e.g. a Vietnam project querying Germany must not default to
* Vietnamese).
*/
export function resolveMarket(
args: { locationCode?: number; languageCode?: string },
project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } {
const locationCode = args.locationCode ?? project.locationCode;
const languageCode =
args.languageCode ??
(locationCode === project.locationCode
? project.languageCode
: getLanguageCode(locationCode));
return { locationCode, languageCode };
}
/**
* Whether DataForSEO serves this language for this location. Only Labs
* locations have authoritative per-location language lists; Google Ads
* locations are left to the metering safety net.
*/
export function isLanguageServedForLocation(
locationCode: number,
languageCode: string,
): boolean {
if (getKeywordDataProvider(locationCode) !== "labs") return true;
return getLanguageOptions(locationCode).some(
(option) => option.code === languageCode,
);
}
/**
* Resolves the market for a Labs-only tool. Same as resolveMarket, except a
* project default Labs cannot serve is replaced by the United States: the
* caller never chose that market, so rejecting the call would dead-end on a
* value it can't see and passing the pair through would spend credits on a
* task DataForSEO rejects. An explicit location is left alone, so a caller that
* names an unserved country still fails loudly on its own assert.
*/
export function resolveLabsMarket(
args: { locationCode?: number; languageCode?: string },
project: { locationCode: number; languageCode: string },
): { locationCode: number; languageCode: string } {
const projectIsServed =
getKeywordDataProvider(project.locationCode) === "labs" &&
isLanguageServedForLocation(project.locationCode, project.languageCode);
return resolveMarket(
args,
projectIsServed
? project
: { locationCode: DEFAULT_LOCATION_CODE, languageCode: "en" },
);
}
/**
* Language codes DataForSEO accepts the master LANGUAGE_OPTIONS list. Callers
* (e.g. MCP tools) can pass an arbitrary `language_code`; an unsupported one is
@ -738,9 +798,9 @@ const MULTI_LANGUAGE_LOCATIONS: Record<number, readonly string[]> = {
};
/**
* Languages to offer for a location's rank-tracking config. Restricts the
* global LANGUAGE_OPTIONS list to the languages DataForSEO supports for that
* country, so the picker isn't a wall of irrelevant options.
* Languages to offer for a location. Restricts the global LANGUAGE_OPTIONS
* list to the languages DataForSEO supports for that country, so a picker
* isn't a wall of irrelevant options.
*/
export function getLanguageOptions(
locationCode: number,

View File

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
LABS_LOCATION_OPTIONS,
getLanguageCode,
isLabsLocationCode,
} from "./keyword-locations";
/**
* DataForSEO's own default language for each Labs location: the language with
* the most keyword records, which is what Labs uses when a call omits
* language_code. Snapshot of the free
* GET /v3/dataforseo_labs/locations_and_languages, taken 2026-07-16.
*
* LOCATION_LANGUAGE is hand-maintained, so it can drift from what DataForSEO
* actually serves a drifted entry sends the wrong-language data for a
* country without failing. Refresh this snapshot with:
*
* curl -s https://api.dataforseo.com/v3/dataforseo_labs/locations_and_languages \
* -H "Authorization: Basic $DATAFORSEO_API_KEY" \
* | jq -r '.tasks[0].result | sort_by(.location_code)[]
* | " \(.location_code): \"\(.available_languages | max_by(.keywords) | .language_code)\", // \(.location_name)"'
*
* Google-Ads-only countries are absent by design: Labs holds no data for them,
* so their language has no vendor answer to check against.
*/
const VENDOR_DEFAULT_LANGUAGE: Record<number, string> = {
2008: "sq", // Albania
2012: "fr", // Algeria
2024: "pt", // Angola
2031: "az", // Azerbaijan
2032: "es", // Argentina
2036: "en", // Australia
2040: "de", // Austria
2048: "ar", // Bahrain
2050: "bn", // Bangladesh
2051: "hy", // Armenia
2056: "nl", // Belgium
2068: "es", // Bolivia
2070: "bs", // Bosnia and Herzegovina
2076: "pt", // Brazil
2100: "bg", // Bulgaria
2104: "en", // Myanmar (Burma)
2116: "en", // Cambodia
2120: "fr", // Cameroon
2124: "en", // Canada
2144: "en", // Sri Lanka
2152: "es", // Chile
2158: "zh-TW", // Taiwan
2170: "es", // Colombia
2188: "es", // Costa Rica
2191: "hr", // Croatia
2196: "el", // Cyprus
2203: "cs", // Czechia
2208: "da", // Denmark
2218: "es", // Ecuador
2222: "es", // El Salvador
2233: "et", // Estonia
2246: "fi", // Finland
2250: "fr", // France
2276: "de", // Germany
2288: "en", // Ghana
2300: "el", // Greece
2320: "es", // Guatemala
2344: "zh-TW", // Hong Kong
2348: "hu", // Hungary
2356: "en", // India
2360: "id", // Indonesia
2372: "en", // Ireland
2376: "he", // Israel
2380: "it", // Italy
2384: "fr", // Cote d'Ivoire
2392: "ja", // Japan
2398: "ru", // Kazakhstan
2400: "ar", // Jordan
2404: "en", // Kenya
2410: "ko", // South Korea
2428: "lv", // Latvia
2440: "lt", // Lithuania
2458: "en", // Malaysia
2470: "en", // Malta
2484: "es", // Mexico
2492: "fr", // Monaco
2498: "ro", // Moldova
2504: "ar", // Morocco
2528: "nl", // Netherlands
2554: "en", // New Zealand
2558: "es", // Nicaragua
2566: "en", // Nigeria
2578: "nb", // Norway
2586: "en", // Pakistan
2591: "es", // Panama
2600: "es", // Paraguay
2604: "es", // Peru
2608: "en", // Philippines
2616: "pl", // Poland
2620: "pt", // Portugal
2642: "ro", // Romania
2682: "ar", // Saudi Arabia
2686: "fr", // Senegal
2688: "sr", // Serbia
2702: "en", // Singapore
2703: "sk", // Slovakia
2704: "vi", // Vietnam
2705: "sl", // Slovenia
2710: "en", // South Africa
2724: "es", // Spain
2752: "sv", // Sweden
2756: "de", // Switzerland
2764: "th", // Thailand
2784: "en", // United Arab Emirates
2788: "ar", // Tunisia
2792: "tr", // Turkiye
2804: "uk", // Ukraine
2807: "mk", // North Macedonia
2818: "ar", // Egypt
2826: "en", // United Kingdom
2840: "en", // United States
2854: "fr", // Burkina Faso
2858: "es", // Uruguay
2862: "es", // Venezuela
};
describe("getLanguageCode vs DataForSEO's Labs defaults", () => {
it("uses DataForSEO's default language for every Labs location", () => {
const drifted = Object.entries(VENDOR_DEFAULT_LANGUAGE)
.filter(([code, language]) => getLanguageCode(Number(code)) !== language)
.map(
([code, language]) =>
`${code}: ours=${getLanguageCode(Number(code))} vendor=${language}`,
);
expect(drifted).toEqual([]);
});
it("classifies every location DataForSEO serves from Labs as a Labs location", () => {
const misclassified = Object.entries(VENDOR_DEFAULT_LANGUAGE)
.filter(([code]) => !isLabsLocationCode(Number(code)))
.map(([code, language]) => `${code}: vendor serves ${language} via Labs`);
expect(misclassified).toEqual([]);
});
it("covers every Labs location we offer, so a new one can't skip the check", () => {
const unverified = LABS_LOCATION_OPTIONS.filter(
(option) => VENDOR_DEFAULT_LANGUAGE[option.code] == null,
).map((option) => `${option.code} ${option.label}`);
expect(unverified).toEqual([]);
});
});

View File

@ -59,8 +59,8 @@ export const domainOverviewSchema = z.object({
projectId: z.string().uuid(),
domain: z.string().min(1, "Domain is required").max(255),
includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
});
/* ------------------------------------------------------------------ */
@ -74,8 +74,8 @@ const domainTabs = ["keywords", "pages"] as const;
export const domainKeywordSuggestionsSchema = z.object({
projectId: z.string().uuid(),
domain: domainField,
locationCode: z.number().int().positive(),
languageCode: z.string().min(2).max(8),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
});
export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const;
@ -119,8 +119,8 @@ export const domainKeywordsPageRequestSchema = z.object({
projectId: z.string().uuid(),
domain: z.string().min(1).max(255),
includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
page: z.number().int().positive().default(1),
pageSize: z
.number()
@ -141,8 +141,8 @@ export const domainPagesPageRequestSchema = z.object({
projectId: z.string().uuid(),
domain: z.string().min(1).max(255),
includeSubdomains: z.boolean().default(true),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
page: z.number().int().positive().default(1),
pageSize: z
.number()

View File

@ -18,8 +18,8 @@ const sortDirs = ["asc", "desc"] as const;
export const researchKeywordsSchema = z.object({
projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(200),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
resultLimit: z
.union([z.literal(150), z.literal(300), z.literal(500)])
.default(150),
@ -35,8 +35,8 @@ export const saveKeywordsSchema = z
.object({
projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(500),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
tags: z.array(savedKeywordTagSchema).max(20).optional(),
tagMode: z.enum(["append", "replace"]).optional(),
metrics: z
@ -149,6 +149,17 @@ export const refreshSavedKeywordMetricsSchema = z.object({
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
type ResolvedMarket = { locationCode: number; languageCode: string };
export type ResolvedResearchKeywordsInput = Omit<
ResearchKeywordsInput,
keyof ResolvedMarket
> &
ResolvedMarket;
export type ResolvedSaveKeywordsInput = Omit<
SaveKeywordsInput,
keyof ResolvedMarket
> &
ResolvedMarket;
export type RemoveSavedKeywordsInput = z.infer<
typeof removeSavedKeywordsSchema
>;
@ -172,8 +183,8 @@ export type RefreshSavedKeywordMetricsInput = z.infer<
export const serpAnalysisSchema = z.object({
projectId: z.string().min(1),
keyword: z.string().min(1),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
locationCode: z.number().int().positive().optional(),
languageCode: z.string().min(2).max(8).optional(),
});
/* ------------------------------------------------------------------ */

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { createProjectSchema, updateProjectSchema } from "./projects";
// The service derives a missing language from the location, so a language
// arriving on its own has nothing to validate against.
describe("project market fields", () => {
it("rejects a language with no location", () => {
expect(
updateProjectSchema.safeParse({
projectId: "project_1",
name: "Acme",
languageCode: "vi",
}).success,
).toBe(false);
expect(
createProjectSchema.safeParse({ name: "Acme", languageCode: "vi" })
.success,
).toBe(false);
});
it("accepts a location on its own, and a full pair", () => {
expect(
createProjectSchema.safeParse({ name: "Acme", locationCode: 2704 })
.success,
).toBe(true);
expect(
updateProjectSchema.safeParse({
projectId: "project_1",
name: "Acme",
locationCode: 2704,
languageCode: "vi",
}).success,
).toBe(true);
});
it("accepts a project with no market at all", () => {
expect(createProjectSchema.safeParse({ name: "Acme" }).success).toBe(true);
});
});

View File

@ -1,4 +1,8 @@
import { z } from "zod";
import {
isSupportedLanguageCode,
isSupportedLocationCode,
} from "@/shared/keyword-locations";
const projectNameField = z
.string()
@ -13,15 +17,63 @@ const projectDomainField = z
.transform((value) => value || undefined)
.optional();
export const createProjectSchema = z.object({
// Default market for the project's data calls. The location/language PAIR is
// validated in the service (an update may change one side and needs the
// stored row for the other).
const projectLocationCodeField = z
.number()
.int()
.refine(isSupportedLocationCode, "Unsupported DataForSEO location code")
.optional();
const projectLanguageCodeField = z
.string()
.refine(isSupportedLanguageCode, "Unsupported language code")
.optional();
// A language on its own has no location to validate against, and would force a
// read of the stored row to resolve. Callers set the market as a pair, or send
// a location alone and let the service derive its language.
const hasLocationForLanguage = (input: {
locationCode?: number;
languageCode?: string;
}) => input.locationCode != null || input.languageCode == null;
const marketPairMessage = {
message: "A language requires a location.",
path: ["languageCode"],
};
export const createProjectSchema = z
.object({
name: projectNameField,
domain: projectDomainField,
});
locationCode: projectLocationCodeField,
languageCode: projectLanguageCodeField,
})
.refine(hasLocationForLanguage, marketPairMessage);
export const updateProjectSchema = z.object({
export const updateProjectSchema = z
.object({
projectId: z.string().min(1),
name: projectNameField,
domain: projectDomainField,
locationCode: projectLocationCodeField,
languageCode: projectLanguageCodeField,
})
.refine(hasLocationForLanguage, marketPairMessage);
// Market-only update (onboarding). Both halves are required: the caller picks
// them together, so the service can validate the pair without a stored row.
export const setProjectMarketSchema = z.object({
projectId: z.string().min(1),
locationCode: z
.number()
.int()
.refine(isSupportedLocationCode, "Unsupported DataForSEO location code"),
languageCode: z
.string()
.refine(isSupportedLanguageCode, "Unsupported language code"),
});
export const archiveProjectSchema = z.object({
@ -37,5 +89,6 @@ export const restoreProjectSchema = z.object({
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;