feat: configurable default market for location/language fallbacks (#72)
This commit is contained in:
parent
c1121bdcab
commit
0f08437cd0
@ -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, {
|
||||
|
||||
@ -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) => {
|
||||
if (input.type !== "domain") return "";
|
||||
const locationSuffix =
|
||||
input.locationCode === DEFAULT_LOCATION_CODE
|
||||
? ""
|
||||
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
|
||||
return `${input.domain}${locationSuffix}`;
|
||||
}, []),
|
||||
getLabel: useCallback(
|
||||
(input) => {
|
||||
if (input.type !== "domain") return "";
|
||||
const locationSuffix =
|
||||
input.locationCode == null ||
|
||||
input.locationCode === routeState.defaultLocationCode
|
||||
? ""
|
||||
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
|
||||
return `${input.domain}${locationSuffix}`;
|
||||
},
|
||||
[routeState.defaultLocationCode],
|
||||
),
|
||||
navigateToInput: navigateToSearchTab,
|
||||
});
|
||||
|
||||
@ -623,7 +636,6 @@ export function DomainOverviewPage({
|
||||
key="keywords"
|
||||
projectId={projectId}
|
||||
domain={state.overview.domain}
|
||||
languageCode={state.languageCode}
|
||||
routeState={routeState}
|
||||
canSaveKeywords={state.canSaveKeywords}
|
||||
setSearchParams={state.setSearchParams}
|
||||
@ -636,7 +648,6 @@ export function DomainOverviewPage({
|
||||
key="pages"
|
||||
projectId={projectId}
|
||||
domain={state.overview.domain}
|
||||
languageCode={state.languageCode}
|
||||
routeState={routeState}
|
||||
setSearchParams={state.setSearchParams}
|
||||
onSortClick={state.handleSortColumnClick}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
48
src/client/features/domain/domainRouteState.test.ts
Normal file
48
src/client/features/domain/domainRouteState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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 ?? "",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -11,12 +11,14 @@ import {
|
||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
|
||||
import type { ProjectMarket } from "@/client/features/projects/types";
|
||||
import {
|
||||
getGscConnection,
|
||||
listGscSites,
|
||||
setGscSite,
|
||||
} from "@/serverFunctions/gsc";
|
||||
import { getProjects } from "@/serverFunctions/projects";
|
||||
import { getProjects, setProjectMarket } from "@/serverFunctions/projects";
|
||||
|
||||
const GRANT_STATUS_KEY = ["gscGrantStatus"];
|
||||
|
||||
@ -31,19 +33,66 @@ export function SearchConsoleOnboardingStep() {
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
const projectId = projectsQuery.data?.[0]?.id;
|
||||
const project = projectsQuery.data?.[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Connect with Google Search Console now?
|
||||
</h2>
|
||||
<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 & 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">
|
||||
For now, Search Console data flows through the OpenSEO MCP. We're
|
||||
building it into the OpenSEO app soon too.
|
||||
We'll use this country and language for keyword, SERP, and domain data
|
||||
unless you pick a different one. You can change it in project settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
58
src/client/features/projects/ProjectMarketFields.tsx
Normal file
58
src/client/features/projects/ProjectMarketFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
13
src/client/features/projects/useProjectMarket.ts
Normal file
13
src/client/features/projects/useProjectMarket.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) : "";
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
43
src/server/lib/market.ts
Normal 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(", ")}.`,
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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).",
|
||||
);
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
114
src/server/mcp/tools/dataforseo-research-tools.market.test.ts
Normal file
114
src/server/mcp/tools/dataforseo-research-tools.market.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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}`),
|
||||
})),
|
||||
},
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
147
src/shared/keyword-locations.vendor-defaults.test.ts
Normal file
147
src/shared/keyword-locations.vendor-defaults.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -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()
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
39
src/types/schemas/projects.test.ts
Normal file
39
src/types/schemas/projects.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
name: projectNameField,
|
||||
domain: projectDomainField,
|
||||
});
|
||||
// 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();
|
||||
|
||||
export const updateProjectSchema = z.object({
|
||||
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({
|
||||
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),
|
||||
name: projectNameField,
|
||||
domain: projectDomainField,
|
||||
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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user