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 { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { ResearchKeywordsInput } from "@/types/schemas/keywords";
|
import type { ResolvedResearchKeywordsInput } from "@/types/schemas/keywords";
|
||||||
|
|
||||||
const MONTHLY_SEARCHES = [
|
const MONTHLY_SEARCHES = [
|
||||||
{ year: 2025, month: 4, searchVolume: 1200 },
|
{ 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 seedKeyword = data.keywords[0] ?? "keyword research";
|
||||||
const rows = [
|
const rows = [
|
||||||
makeRow(seedKeyword, 0, {
|
makeRow(seedKeyword, 0, {
|
||||||
|
|||||||
@ -8,9 +8,7 @@ import {
|
|||||||
type DomainSearchParams,
|
type DomainSearchParams,
|
||||||
} from "@/types/schemas/domain";
|
} from "@/types/schemas/domain";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
LOCATIONS,
|
LOCATIONS,
|
||||||
getLanguageCode,
|
|
||||||
isLabsLocationCode,
|
isLabsLocationCode,
|
||||||
} from "@/client/features/keywords/locations";
|
} from "@/client/features/keywords/locations";
|
||||||
import { useDomainSearchHistory } from "@/client/hooks/useDomainSearchHistory";
|
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 {
|
return {
|
||||||
loc:
|
loc:
|
||||||
nextLocationCode === DEFAULT_LOCATION_CODE ? undefined : nextLocationCode,
|
nextLocationCode === defaultLocationCode ? undefined : nextLocationCode,
|
||||||
page: undefined,
|
page: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -122,11 +123,12 @@ function getTabSearchUpdate(
|
|||||||
|
|
||||||
function getHistorySearchUpdate(
|
function getHistorySearchUpdate(
|
||||||
item: DomainSearchHistoryItem,
|
item: DomainSearchHistoryItem,
|
||||||
|
defaultLocationCode: number,
|
||||||
): DomainSearchUpdate {
|
): DomainSearchUpdate {
|
||||||
const historyLocation =
|
const historyLocation =
|
||||||
item.locationCode != null && isLabsLocationCode(item.locationCode)
|
item.locationCode != null && isLabsLocationCode(item.locationCode)
|
||||||
? item.locationCode
|
? item.locationCode
|
||||||
: DEFAULT_LOCATION_CODE;
|
: defaultLocationCode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildDomainFiltersClearSearchUpdate(),
|
...buildDomainFiltersClearSearchUpdate(),
|
||||||
@ -135,8 +137,7 @@ function getHistorySearchUpdate(
|
|||||||
sort: toSortSearchParam(item.sort),
|
sort: toSortSearchParam(item.sort),
|
||||||
order: undefined,
|
order: undefined,
|
||||||
tab: item.tab === "keywords" ? undefined : item.tab,
|
tab: item.tab === "keywords" ? undefined : item.tab,
|
||||||
loc:
|
loc: historyLocation === defaultLocationCode ? undefined : historyLocation,
|
||||||
historyLocation === DEFAULT_LOCATION_CODE ? undefined : historyLocation,
|
|
||||||
size: undefined,
|
size: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -148,6 +149,7 @@ function getSearchSubmitUpdate({
|
|||||||
locationCode,
|
locationCode,
|
||||||
currentOrder,
|
currentOrder,
|
||||||
activeTab,
|
activeTab,
|
||||||
|
defaultLocationCode,
|
||||||
}: {
|
}: {
|
||||||
domain: string;
|
domain: string;
|
||||||
subdomains: boolean;
|
subdomains: boolean;
|
||||||
@ -155,6 +157,7 @@ function getSearchSubmitUpdate({
|
|||||||
locationCode: number;
|
locationCode: number;
|
||||||
currentOrder: SortOrder;
|
currentOrder: SortOrder;
|
||||||
activeTab: DomainActiveTab;
|
activeTab: DomainActiveTab;
|
||||||
|
defaultLocationCode: number;
|
||||||
}): DomainSearchUpdate {
|
}): DomainSearchUpdate {
|
||||||
return {
|
return {
|
||||||
...buildDomainFiltersClearSearchUpdate(),
|
...buildDomainFiltersClearSearchUpdate(),
|
||||||
@ -163,7 +166,7 @@ function getSearchSubmitUpdate({
|
|||||||
sort: toSortSearchParam(sort),
|
sort: toSortSearchParam(sort),
|
||||||
order: toSortOrderSearchParam(sort, currentOrder),
|
order: toSortOrderSearchParam(sort, currentOrder),
|
||||||
tab: activeTab === "keywords" ? undefined : activeTab,
|
tab: activeTab === "keywords" ? undefined : activeTab,
|
||||||
loc: locationCode === DEFAULT_LOCATION_CODE ? undefined : locationCode,
|
loc: locationCode === defaultLocationCode ? undefined : locationCode,
|
||||||
size: undefined,
|
size: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -205,9 +208,14 @@ function useDomainOverviewState({
|
|||||||
|
|
||||||
const applyLocationChange = useCallback(
|
const applyLocationChange = useCallback(
|
||||||
(nextLocationCode: number) => {
|
(nextLocationCode: number) => {
|
||||||
setSearchParams(getLocationSearchUpdate(nextLocationCode));
|
setSearchParams(
|
||||||
|
getLocationSearchUpdate(
|
||||||
|
nextLocationCode,
|
||||||
|
routeState.defaultLocationCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[setSearchParams],
|
[routeState.defaultLocationCode, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortColumnClick = useCallback(
|
const handleSortColumnClick = useCallback(
|
||||||
@ -246,21 +254,21 @@ function useDomainOverviewState({
|
|||||||
|
|
||||||
const handleHistorySelect = useCallback(
|
const handleHistorySelect = useCallback(
|
||||||
(item: DomainSearchHistoryItem) => {
|
(item: DomainSearchHistoryItem) => {
|
||||||
setSearchParams(getHistorySearchUpdate(item));
|
setSearchParams(
|
||||||
|
getHistorySearchUpdate(item, routeState.defaultLocationCode),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[setSearchParams],
|
[routeState.defaultLocationCode, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const languageCode = getLanguageCode(routeState.locationCode);
|
|
||||||
const overviewQuery = useDomainOverviewQuery({
|
const overviewQuery = useDomainOverviewQuery({
|
||||||
projectId,
|
projectId,
|
||||||
domain: routeState.domain,
|
domain: routeState.domain,
|
||||||
includeSubdomains: routeState.subdomains,
|
includeSubdomains: routeState.subdomains,
|
||||||
locationCode: routeState.locationCode,
|
locationCode: routeState.sentLocationCode,
|
||||||
languageCode,
|
|
||||||
});
|
});
|
||||||
const overview = overviewQuery.data ?? null;
|
const overview = overviewQuery.data ?? null;
|
||||||
const isLoading = overviewQuery.isLoading;
|
const isLoading = routeState.domain.trim() !== "" && overviewQuery.isLoading;
|
||||||
|
|
||||||
const controlsForm = useForm({
|
const controlsForm = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -290,6 +298,7 @@ function useDomainOverviewState({
|
|||||||
locationCode: value.locationCode,
|
locationCode: value.locationCode,
|
||||||
currentOrder: routeState.order,
|
currentOrder: routeState.order,
|
||||||
activeTab: routeState.tab,
|
activeTab: routeState.tab,
|
||||||
|
defaultLocationCode: routeState.defaultLocationCode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -389,7 +398,6 @@ function useDomainOverviewState({
|
|||||||
history,
|
history,
|
||||||
historyLoaded,
|
historyLoaded,
|
||||||
removeHistoryItem,
|
removeHistoryItem,
|
||||||
languageCode,
|
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
applySort,
|
applySort,
|
||||||
applyLocationChange,
|
applyLocationChange,
|
||||||
@ -412,16 +420,20 @@ export function DomainOverviewPage({
|
|||||||
navigate,
|
navigate,
|
||||||
onShowRecentSearches,
|
onShowRecentSearches,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const state = useDomainOverviewState({ navigate, routeState, projectId });
|
const state = useDomainOverviewState({
|
||||||
|
navigate,
|
||||||
|
routeState,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
const urlTabInput = useMemo<SearchTabInput | null>(() => {
|
const urlTabInput = useMemo<SearchTabInput | null>(() => {
|
||||||
if (routeState.domain.trim() === "") return null;
|
if (routeState.domain.trim() === "") return null;
|
||||||
return {
|
return {
|
||||||
type: "domain",
|
type: "domain",
|
||||||
domain: routeState.domain,
|
domain: routeState.domain,
|
||||||
subdomains: routeState.subdomains,
|
subdomains: routeState.subdomains,
|
||||||
locationCode: routeState.locationCode,
|
locationCode: routeState.sentLocationCode,
|
||||||
};
|
};
|
||||||
}, [routeState.domain, routeState.locationCode, routeState.subdomains]);
|
}, [routeState.domain, routeState.sentLocationCode, routeState.subdomains]);
|
||||||
|
|
||||||
const navigateToSearchTab = useCallback(
|
const navigateToSearchTab = useCallback(
|
||||||
(input: SearchTabInput | null) => {
|
(input: SearchTabInput | null) => {
|
||||||
@ -443,10 +455,7 @@ export function DomainOverviewPage({
|
|||||||
order: undefined,
|
order: undefined,
|
||||||
tab: undefined,
|
tab: undefined,
|
||||||
page: undefined,
|
page: undefined,
|
||||||
loc:
|
loc: input.locationCode,
|
||||||
input.locationCode === DEFAULT_LOCATION_CODE
|
|
||||||
? undefined
|
|
||||||
: input.locationCode,
|
|
||||||
size: undefined,
|
size: undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
@ -458,14 +467,18 @@ export function DomainOverviewPage({
|
|||||||
const searchTabs = useSearchTabNavigation({
|
const searchTabs = useSearchTabNavigation({
|
||||||
storageKey: `domain:${projectId}`,
|
storageKey: `domain:${projectId}`,
|
||||||
urlInput: urlTabInput,
|
urlInput: urlTabInput,
|
||||||
getLabel: useCallback((input) => {
|
getLabel: useCallback(
|
||||||
if (input.type !== "domain") return "";
|
(input) => {
|
||||||
const locationSuffix =
|
if (input.type !== "domain") return "";
|
||||||
input.locationCode === DEFAULT_LOCATION_CODE
|
const locationSuffix =
|
||||||
? ""
|
input.locationCode == null ||
|
||||||
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
|
input.locationCode === routeState.defaultLocationCode
|
||||||
return `${input.domain}${locationSuffix}`;
|
? ""
|
||||||
}, []),
|
: ` ${LOCATIONS[input.locationCode] ?? input.locationCode}`;
|
||||||
|
return `${input.domain}${locationSuffix}`;
|
||||||
|
},
|
||||||
|
[routeState.defaultLocationCode],
|
||||||
|
),
|
||||||
navigateToInput: navigateToSearchTab,
|
navigateToInput: navigateToSearchTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -623,7 +636,6 @@ export function DomainOverviewPage({
|
|||||||
key="keywords"
|
key="keywords"
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
domain={state.overview.domain}
|
domain={state.overview.domain}
|
||||||
languageCode={state.languageCode}
|
|
||||||
routeState={routeState}
|
routeState={routeState}
|
||||||
canSaveKeywords={state.canSaveKeywords}
|
canSaveKeywords={state.canSaveKeywords}
|
||||||
setSearchParams={state.setSearchParams}
|
setSearchParams={state.setSearchParams}
|
||||||
@ -636,7 +648,6 @@ export function DomainOverviewPage({
|
|||||||
key="pages"
|
key="pages"
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
domain={state.overview.domain}
|
domain={state.overview.domain}
|
||||||
languageCode={state.languageCode}
|
|
||||||
routeState={routeState}
|
routeState={routeState}
|
||||||
setSearchParams={state.setSearchParams}
|
setSearchParams={state.setSearchParams}
|
||||||
onSortClick={state.handleSortColumnClick}
|
onSortClick={state.handleSortColumnClick}
|
||||||
|
|||||||
@ -65,7 +65,6 @@ const KEYWORD_RANGE_FILTERS = [
|
|||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
languageCode: string;
|
|
||||||
routeState: DomainOverviewRouteState;
|
routeState: DomainOverviewRouteState;
|
||||||
canSaveKeywords: boolean;
|
canSaveKeywords: boolean;
|
||||||
setSearchParams: (updates: SearchUpdate) => void;
|
setSearchParams: (updates: SearchUpdate) => void;
|
||||||
@ -77,7 +76,6 @@ type Props = {
|
|||||||
export function KeywordsTab({
|
export function KeywordsTab({
|
||||||
projectId,
|
projectId,
|
||||||
domain,
|
domain,
|
||||||
languageCode,
|
|
||||||
routeState,
|
routeState,
|
||||||
canSaveKeywords,
|
canSaveKeywords,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
@ -106,8 +104,7 @@ export function KeywordsTab({
|
|||||||
projectId,
|
projectId,
|
||||||
domain,
|
domain,
|
||||||
includeSubdomains: routeState.subdomains,
|
includeSubdomains: routeState.subdomains,
|
||||||
locationCode: routeState.locationCode,
|
locationCode: routeState.sentLocationCode,
|
||||||
languageCode,
|
|
||||||
page: routeState.page,
|
page: routeState.page,
|
||||||
pageSize: routeState.pageSize,
|
pageSize: routeState.pageSize,
|
||||||
sortMode: routeState.sort,
|
sortMode: routeState.sort,
|
||||||
@ -159,13 +156,11 @@ export function KeywordsTab({
|
|||||||
filteredKeywords: rows,
|
filteredKeywords: rows,
|
||||||
save: saveMutation.mutate,
|
save: saveMutation.mutate,
|
||||||
projectId,
|
projectId,
|
||||||
locationCode: routeState.locationCode,
|
locationCode: routeState.sentLocationCode,
|
||||||
languageCode,
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
languageCode,
|
|
||||||
projectId,
|
projectId,
|
||||||
routeState.locationCode,
|
routeState.sentLocationCode,
|
||||||
rows,
|
rows,
|
||||||
saveMutation.mutate,
|
saveMutation.mutate,
|
||||||
selectedKeywords,
|
selectedKeywords,
|
||||||
|
|||||||
@ -55,7 +55,6 @@ const PAGE_RANGE_FILTERS = [
|
|||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
languageCode: string;
|
|
||||||
routeState: DomainOverviewRouteState;
|
routeState: DomainOverviewRouteState;
|
||||||
setSearchParams: (updates: SearchUpdate) => void;
|
setSearchParams: (updates: SearchUpdate) => void;
|
||||||
onSortClick: (sort: DomainSortMode) => void;
|
onSortClick: (sort: DomainSortMode) => void;
|
||||||
@ -66,7 +65,6 @@ type Props = {
|
|||||||
export function PagesTab({
|
export function PagesTab({
|
||||||
projectId,
|
projectId,
|
||||||
domain,
|
domain,
|
||||||
languageCode,
|
|
||||||
routeState,
|
routeState,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
onSortClick,
|
onSortClick,
|
||||||
@ -98,8 +96,7 @@ export function PagesTab({
|
|||||||
projectId,
|
projectId,
|
||||||
domain,
|
domain,
|
||||||
includeSubdomains: routeState.subdomains,
|
includeSubdomains: routeState.subdomains,
|
||||||
locationCode: routeState.locationCode,
|
locationCode: routeState.sentLocationCode,
|
||||||
languageCode,
|
|
||||||
page: routeState.page,
|
page: routeState.page,
|
||||||
pageSize: routeState.pageSize,
|
pageSize: routeState.pageSize,
|
||||||
sortMode: routeState.sort,
|
sortMode: routeState.sort,
|
||||||
|
|||||||
@ -6,8 +6,7 @@ import type { KeywordRow } from "@/client/features/domain/types";
|
|||||||
type SaveMutation = (payload: {
|
type SaveMutation = (payload: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
locationCode: number;
|
locationCode?: number;
|
||||||
languageCode: string;
|
|
||||||
metrics?: Array<{
|
metrics?: Array<{
|
||||||
keyword: string;
|
keyword: string;
|
||||||
searchVolume?: number | null;
|
searchVolume?: number | null;
|
||||||
@ -27,14 +26,12 @@ export function saveSelectedKeywords({
|
|||||||
save,
|
save,
|
||||||
projectId,
|
projectId,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
|
||||||
}: {
|
}: {
|
||||||
selectedKeywords: Set<string>;
|
selectedKeywords: Set<string>;
|
||||||
filteredKeywords: KeywordRow[];
|
filteredKeywords: KeywordRow[];
|
||||||
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
|
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
locationCode: number;
|
locationCode?: number;
|
||||||
languageCode: string;
|
|
||||||
}) {
|
}) {
|
||||||
if (selectedKeywords.size === 0) {
|
if (selectedKeywords.size === 0) {
|
||||||
toast.error("Select at least one keyword first");
|
toast.error("Select at least one keyword first");
|
||||||
@ -49,7 +46,6 @@ export function saveSelectedKeywords({
|
|||||||
projectId,
|
projectId,
|
||||||
keywords: [...selectedKeywords],
|
keywords: [...selectedKeywords],
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
|
||||||
metrics: selectedRows.map((row) => ({
|
metrics: selectedRows.map((row) => ({
|
||||||
keyword: row.keyword,
|
keyword: row.keyword,
|
||||||
searchVolume: row.searchVolume,
|
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,
|
DEFAULT_LOCATION_CODE,
|
||||||
isLabsLocationCode,
|
isLabsLocationCode,
|
||||||
} from "@/client/features/keywords/locations";
|
} from "@/client/features/keywords/locations";
|
||||||
|
import type { ProjectMarket } from "@/client/features/projects/types";
|
||||||
import {
|
import {
|
||||||
EMPTY_DOMAIN_FILTERS,
|
EMPTY_DOMAIN_FILTERS,
|
||||||
type DomainActiveTab,
|
type DomainActiveTab,
|
||||||
@ -28,7 +29,9 @@ export type DomainOverviewRouteState = {
|
|||||||
sort: DomainSortMode;
|
sort: DomainSortMode;
|
||||||
order: SortOrder;
|
order: SortOrder;
|
||||||
tab: DomainActiveTab;
|
tab: DomainActiveTab;
|
||||||
|
defaultLocationCode: number;
|
||||||
locationCode: number;
|
locationCode: number;
|
||||||
|
sentLocationCode: number | undefined;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
appliedFilters: DomainFilterValues;
|
appliedFilters: DomainFilterValues;
|
||||||
@ -44,13 +47,18 @@ function numberToFilterString(value: number | undefined): string {
|
|||||||
|
|
||||||
export function getDomainRouteState(
|
export function getDomainRouteState(
|
||||||
search: DomainSearchParams,
|
search: DomainSearchParams,
|
||||||
|
projectMarket?: ProjectMarket,
|
||||||
): DomainOverviewRouteState {
|
): DomainOverviewRouteState {
|
||||||
const normalizedSort = toSortMode(search.sort ?? null) ?? "traffic";
|
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.
|
// Domain analytics is Labs-backed; Google-Ads-only countries aren't valid.
|
||||||
const normalizedLocationCode =
|
const normalizedLocationCode =
|
||||||
search.loc != null && isLabsLocationCode(search.loc)
|
search.loc != null && isLabsLocationCode(search.loc)
|
||||||
? search.loc
|
? search.loc
|
||||||
: DEFAULT_LOCATION_CODE;
|
: defaultLocationCode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain: search.domain ?? "",
|
domain: search.domain ?? "",
|
||||||
@ -58,7 +66,9 @@ export function getDomainRouteState(
|
|||||||
sort: normalizedSort,
|
sort: normalizedSort,
|
||||||
order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)),
|
order: resolveSortOrder(normalizedSort, toSortOrder(search.order ?? null)),
|
||||||
tab: search.tab ?? "keywords",
|
tab: search.tab ?? "keywords",
|
||||||
|
defaultLocationCode,
|
||||||
locationCode: normalizedLocationCode,
|
locationCode: normalizedLocationCode,
|
||||||
|
sentLocationCode: search.loc,
|
||||||
page: search.page != null && search.page > 0 ? search.page : 1,
|
page: search.page != null && search.page > 0 ? search.page : 1,
|
||||||
pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
pageSize: search.size ?? DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||||
appliedFilters: {
|
appliedFilters: {
|
||||||
|
|||||||
@ -12,8 +12,7 @@ type DomainKeywordsQueryInput = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
includeSubdomains: boolean;
|
includeSubdomains: boolean;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
languageCode: string;
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
sortMode: DomainSortMode;
|
sortMode: DomainSortMode;
|
||||||
@ -60,7 +59,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
|
|||||||
input.domain,
|
input.domain,
|
||||||
input.includeSubdomains,
|
input.includeSubdomains,
|
||||||
input.locationCode,
|
input.locationCode,
|
||||||
input.languageCode,
|
|
||||||
input.page,
|
input.page,
|
||||||
input.pageSize,
|
input.pageSize,
|
||||||
input.sortMode,
|
input.sortMode,
|
||||||
@ -71,7 +69,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
|
|||||||
filtersPayload,
|
filtersPayload,
|
||||||
input.domain,
|
input.domain,
|
||||||
input.includeSubdomains,
|
input.includeSubdomains,
|
||||||
input.languageCode,
|
|
||||||
input.locationCode,
|
input.locationCode,
|
||||||
input.page,
|
input.page,
|
||||||
input.pageSize,
|
input.pageSize,
|
||||||
@ -98,7 +95,6 @@ export function useDomainKeywordsQuery(input: DomainKeywordsQueryInput) {
|
|||||||
domain: input.domain,
|
domain: input.domain,
|
||||||
includeSubdomains: input.includeSubdomains,
|
includeSubdomains: input.includeSubdomains,
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: input.languageCode,
|
|
||||||
page: input.page,
|
page: input.page,
|
||||||
pageSize: input.pageSize,
|
pageSize: input.pageSize,
|
||||||
sortMode: input.sortMode,
|
sortMode: input.sortMode,
|
||||||
|
|||||||
@ -5,8 +5,7 @@ type Input = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
includeSubdomains: boolean;
|
includeSubdomains: boolean;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
languageCode: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDomainOverviewQuery(input: Input) {
|
export function useDomainOverviewQuery(input: Input) {
|
||||||
@ -20,7 +19,6 @@ export function useDomainOverviewQuery(input: Input) {
|
|||||||
trimmedDomain,
|
trimmedDomain,
|
||||||
input.includeSubdomains,
|
input.includeSubdomains,
|
||||||
input.locationCode,
|
input.locationCode,
|
||||||
input.languageCode,
|
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getDomainOverview({
|
getDomainOverview({
|
||||||
@ -29,7 +27,6 @@ export function useDomainOverviewQuery(input: Input) {
|
|||||||
domain: trimmedDomain,
|
domain: trimmedDomain,
|
||||||
includeSubdomains: input.includeSubdomains,
|
includeSubdomains: input.includeSubdomains,
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: input.languageCode,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
|
|||||||
@ -13,8 +13,7 @@ type DomainPagesQueryInput = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
includeSubdomains: boolean;
|
includeSubdomains: boolean;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
languageCode: string;
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
sortMode: DomainSortMode;
|
sortMode: DomainSortMode;
|
||||||
@ -32,7 +31,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
|
|||||||
input.domain,
|
input.domain,
|
||||||
input.includeSubdomains,
|
input.includeSubdomains,
|
||||||
input.locationCode,
|
input.locationCode,
|
||||||
input.languageCode,
|
|
||||||
input.page,
|
input.page,
|
||||||
input.pageSize,
|
input.pageSize,
|
||||||
pageSortMode,
|
pageSortMode,
|
||||||
@ -43,7 +41,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
|
|||||||
input.appliedFilters,
|
input.appliedFilters,
|
||||||
input.domain,
|
input.domain,
|
||||||
input.includeSubdomains,
|
input.includeSubdomains,
|
||||||
input.languageCode,
|
|
||||||
input.locationCode,
|
input.locationCode,
|
||||||
input.page,
|
input.page,
|
||||||
input.pageSize,
|
input.pageSize,
|
||||||
@ -70,7 +67,6 @@ export function useDomainPagesQuery(input: DomainPagesQueryInput) {
|
|||||||
domain: input.domain,
|
domain: input.domain,
|
||||||
includeSubdomains: input.includeSubdomains,
|
includeSubdomains: input.includeSubdomains,
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: input.languageCode,
|
|
||||||
page: input.page,
|
page: input.page,
|
||||||
pageSize: input.pageSize,
|
pageSize: input.pageSize,
|
||||||
sortMode: pageSortMode,
|
sortMode: pageSortMode,
|
||||||
|
|||||||
@ -12,8 +12,7 @@ export function useSaveKeywordsMutation({
|
|||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
locationCode: number;
|
locationCode?: number;
|
||||||
languageCode: string;
|
|
||||||
metrics?: Array<{
|
metrics?: Array<{
|
||||||
keyword: string;
|
keyword: string;
|
||||||
searchVolume?: number | null;
|
searchVolume?: number | null;
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { parseKeywordInput } from "@/client/features/keywords/state/keywordContr
|
|||||||
|
|
||||||
type KeywordTabValidationInput = {
|
type KeywordTabValidationInput = {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
mode: KeywordMode;
|
mode: KeywordMode;
|
||||||
clickstream: boolean;
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
|
import { LOCATIONS } from "@/client/features/keywords/utils";
|
||||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
|
||||||
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
import { researchKeywords } from "@/serverFunctions/keywords";
|
import { researchKeywords } from "@/serverFunctions/keywords";
|
||||||
import type {
|
import type {
|
||||||
@ -18,21 +17,24 @@ type AddSearchFn = (
|
|||||||
locationName: string,
|
locationName: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
type KeywordResearchQueryInput = {
|
type KeywordResearchRequestInput = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
keywordInput: string;
|
keywordInput: string;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
mode: KeywordMode;
|
mode: KeywordMode;
|
||||||
clickstream: boolean;
|
clickstream: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type KeywordResearchQueryInput = KeywordResearchRequestInput & {
|
||||||
|
displayedLocationCode: number;
|
||||||
|
};
|
||||||
|
|
||||||
type KeywordResearchRequest = {
|
type KeywordResearchRequest = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
seedKeyword: string;
|
seedKeyword: string;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
languageCode: string;
|
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
mode: KeywordMode;
|
mode: KeywordMode;
|
||||||
clickstream: boolean;
|
clickstream: boolean;
|
||||||
@ -41,7 +43,7 @@ type KeywordResearchRequest = {
|
|||||||
export const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
|
export const KEYWORD_RESEARCH_STALE_TIME_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export function buildKeywordResearchRequest(
|
export function buildKeywordResearchRequest(
|
||||||
input: KeywordResearchQueryInput,
|
input: KeywordResearchRequestInput,
|
||||||
): KeywordResearchRequest | null {
|
): KeywordResearchRequest | null {
|
||||||
const keywords = parseKeywordInput(input.keywordInput);
|
const keywords = parseKeywordInput(input.keywordInput);
|
||||||
const seedKeyword = keywords[0] ?? "";
|
const seedKeyword = keywords[0] ?? "";
|
||||||
@ -52,7 +54,6 @@ export function buildKeywordResearchRequest(
|
|||||||
keywords,
|
keywords,
|
||||||
seedKeyword,
|
seedKeyword,
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: getLanguageCode(input.locationCode),
|
|
||||||
resultLimit: input.resultLimit,
|
resultLimit: input.resultLimit,
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
clickstream: input.clickstream,
|
clickstream: input.clickstream,
|
||||||
@ -68,7 +69,6 @@ export function buildKeywordResearchQueryKey(
|
|||||||
request.projectId,
|
request.projectId,
|
||||||
request.keywords,
|
request.keywords,
|
||||||
request.locationCode,
|
request.locationCode,
|
||||||
request.languageCode,
|
|
||||||
request.resultLimit,
|
request.resultLimit,
|
||||||
request.mode,
|
request.mode,
|
||||||
request.clickstream,
|
request.clickstream,
|
||||||
@ -82,7 +82,6 @@ export function keywordResearchQueryFn(request: KeywordResearchRequest) {
|
|||||||
projectId: request.projectId,
|
projectId: request.projectId,
|
||||||
keywords: request.keywords,
|
keywords: request.keywords,
|
||||||
locationCode: request.locationCode,
|
locationCode: request.locationCode,
|
||||||
languageCode: request.languageCode,
|
|
||||||
resultLimit: request.resultLimit,
|
resultLimit: request.resultLimit,
|
||||||
mode: request.mode,
|
mode: request.mode,
|
||||||
clickstream: request.clickstream,
|
clickstream: request.clickstream,
|
||||||
@ -96,6 +95,7 @@ export function useKeywordResearchData(
|
|||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
clickstream,
|
clickstream,
|
||||||
|
displayedLocationCode,
|
||||||
keywordInput,
|
keywordInput,
|
||||||
locationCode,
|
locationCode,
|
||||||
mode,
|
mode,
|
||||||
@ -144,7 +144,7 @@ export function useKeywordResearchData(
|
|||||||
handledSuccessKeyRef.current = queryKeyString;
|
handledSuccessKeyRef.current = queryKeyString;
|
||||||
|
|
||||||
captureClientEvent("keyword_research:search_complete", {
|
captureClientEvent("keyword_research:search_complete", {
|
||||||
location_code: request.locationCode,
|
location_code: displayedLocationCode,
|
||||||
search_mode: request.mode,
|
search_mode: request.mode,
|
||||||
clickstream: request.clickstream,
|
clickstream: request.clickstream,
|
||||||
result_count: researchQuery.data.rows.length,
|
result_count: researchQuery.data.rows.length,
|
||||||
@ -152,18 +152,19 @@ export function useKeywordResearchData(
|
|||||||
|
|
||||||
addSearch(
|
addSearch(
|
||||||
request.seedKeyword,
|
request.seedKeyword,
|
||||||
request.locationCode,
|
displayedLocationCode,
|
||||||
LOCATIONS[request.locationCode] || "Unknown",
|
LOCATIONS[displayedLocationCode] || "Unknown",
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
addSearch,
|
addSearch,
|
||||||
|
displayedLocationCode,
|
||||||
queryKeyString,
|
queryKeyString,
|
||||||
request,
|
request,
|
||||||
researchQuery.data,
|
researchQuery.data,
|
||||||
researchQuery.isSuccess,
|
researchQuery.isSuccess,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasSearched = request !== null;
|
const hasSearched = parseKeywordInput(keywordInput).length > 0;
|
||||||
const rows = hasSearched ? (researchQuery.data?.rows ?? []) : [];
|
const rows = hasSearched ? (researchQuery.data?.rows ?? []) : [];
|
||||||
const researchError =
|
const researchError =
|
||||||
hasSearched && researchQuery.isError
|
hasSearched && researchQuery.isError
|
||||||
@ -178,7 +179,7 @@ export function useKeywordResearchData(
|
|||||||
researchQuery.data?.source ?? ("related" as ResearchSource),
|
researchQuery.data?.source ?? ("related" as ResearchSource),
|
||||||
lastUsedFallback: researchQuery.data?.usedFallback ?? false,
|
lastUsedFallback: researchQuery.data?.usedFallback ?? false,
|
||||||
lastSearchKeyword: request?.seedKeyword ?? "",
|
lastSearchKeyword: request?.seedKeyword ?? "",
|
||||||
lastSearchLocationCode: request?.locationCode ?? DEFAULT_LOCATION_CODE,
|
lastSearchLocationCode: displayedLocationCode,
|
||||||
researchError,
|
researchError,
|
||||||
researchMutationError: researchQuery.error,
|
researchMutationError: researchQuery.error,
|
||||||
searchedKeyword: request?.seedKeyword ?? "",
|
searchedKeyword: request?.seedKeyword ?? "",
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { getLanguageCode } from "@/client/features/keywords/utils";
|
|
||||||
import { getSerpAnalysis } from "@/serverFunctions/keywords";
|
import { getSerpAnalysis } from "@/serverFunctions/keywords";
|
||||||
|
|
||||||
export function useKeywordSerpAnalysis(
|
export function useKeywordSerpAnalysis(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
locationCode: number,
|
locationCode: number | undefined,
|
||||||
) {
|
) {
|
||||||
const [serpKeyword, setSerpKeyword] = useState<string | null>(null);
|
const [serpKeyword, setSerpKeyword] = useState<string | null>(null);
|
||||||
const [serpPage, setSerpPage] = useState(0);
|
const [serpPage, setSerpPage] = useState(0);
|
||||||
@ -20,7 +19,6 @@ export function useKeywordSerpAnalysis(
|
|||||||
projectId,
|
projectId,
|
||||||
keyword: serpKeyword!,
|
keyword: serpKeyword!,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode: getLanguageCode(locationCode),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
enabled: !!serpKeyword,
|
enabled: !!serpKeyword,
|
||||||
@ -29,7 +27,7 @@ export function useKeywordSerpAnalysis(
|
|||||||
const serpResults = serpQuery.data?.items ?? [];
|
const serpResults = serpQuery.data?.items ?? [];
|
||||||
const activeSerpKeyword =
|
const activeSerpKeyword =
|
||||||
serpKeyword ?? serpQuery.data?.requestedKeyword ?? null;
|
serpKeyword ?? serpQuery.data?.requestedKeyword ?? null;
|
||||||
const serpLoading = serpQuery.isLoading;
|
const serpLoading = !!serpKeyword && serpQuery.isLoading;
|
||||||
const serpError = serpQuery.isError
|
const serpError = serpQuery.isError
|
||||||
? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.")
|
? getStandardErrorMessage(serpQuery.error, "Failed to load SERP data.")
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@ -5,12 +5,15 @@ import {
|
|||||||
isSupportedLocationCode,
|
isSupportedLocationCode,
|
||||||
} from "@/client/features/keywords/locations";
|
} 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();
|
const locationCodeSchema = z.number().int().positive();
|
||||||
|
|
||||||
function loadPreferredLocationCode() {
|
function loadPreferredLocationCode(projectId: string) {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(storageKey(projectId));
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
const parsed = locationCodeSchema.parse(JSON.parse(raw));
|
const parsed = locationCodeSchema.parse(JSON.parse(raw));
|
||||||
@ -20,31 +23,49 @@ function loadPreferredLocationCode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePreferredLocationCode(locationCode: number) {
|
function savePreferredLocationCode(projectId: string, locationCode: number) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(locationCode));
|
localStorage.setItem(storageKey(projectId), JSON.stringify(locationCode));
|
||||||
} catch {
|
} catch {
|
||||||
// storage full or unavailable - silently ignore
|
// storage full or unavailable - silently ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePreferredKeywordLocation() {
|
/**
|
||||||
const [preferredLocationCode, setPreferredLocationCodeState] = useState(
|
* Preference order: the user's explicit choice for this project (persisted per
|
||||||
DEFAULT_LOCATION_CODE,
|
* 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(() => {
|
useEffect(() => {
|
||||||
const savedLocationCode = loadPreferredLocationCode();
|
if (preference.projectId === projectId) return;
|
||||||
if (savedLocationCode != null) {
|
setPreference({ projectId, locationCode: chosenLocationCode });
|
||||||
setPreferredLocationCodeState(savedLocationCode);
|
}, [chosenLocationCode, preference.projectId, projectId]);
|
||||||
}
|
|
||||||
}, []);
|
const preferredLocationCode =
|
||||||
|
chosenLocationCode ?? projectDefaultLocationCode ?? DEFAULT_LOCATION_CODE;
|
||||||
|
|
||||||
function setPreferredLocationCode(locationCode: number) {
|
function setPreferredLocationCode(locationCode: number) {
|
||||||
if (!isSupportedLocationCode(locationCode)) return;
|
if (!isSupportedLocationCode(locationCode)) return;
|
||||||
setPreferredLocationCodeState(locationCode);
|
setPreference({ projectId, locationCode });
|
||||||
savePreferredLocationCode(locationCode);
|
savePreferredLocationCode(projectId, locationCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { preferredLocationCode, setPreferredLocationCode };
|
return {
|
||||||
|
preferredLocationCode,
|
||||||
|
selectedLocationCode: chosenLocationCode ?? undefined,
|
||||||
|
setPreferredLocationCode,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Clock, Globe, History, Search, X } from "lucide-react";
|
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 { LOCATIONS } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
|
|
||||||
@ -89,10 +88,7 @@ function SearchHistoryState({
|
|||||||
params={{ projectId }}
|
params={{ projectId }}
|
||||||
search={{
|
search={{
|
||||||
q: item.keyword,
|
q: item.keyword,
|
||||||
loc:
|
loc: item.locationCode,
|
||||||
item.locationCode === DEFAULT_LOCATION_CODE
|
|
||||||
? undefined
|
|
||||||
: item.locationCode,
|
|
||||||
}}
|
}}
|
||||||
replace
|
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"
|
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 { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||||
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
|
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
|
||||||
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
import { parseKeywordInput } from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
|
import {
|
||||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
useKeywordSearchParams,
|
||||||
|
useResolvedKeywordLocation,
|
||||||
|
} from "@/client/features/keywords/state/keywordControllerInternals";
|
||||||
import type {
|
import type {
|
||||||
KeywordSearchTabInput,
|
KeywordSearchTabInput,
|
||||||
SearchTab,
|
SearchTab,
|
||||||
} from "@/client/features/search-tabs/types";
|
} from "@/client/features/search-tabs/types";
|
||||||
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
|
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 { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||||
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
||||||
import { KeywordResearchResults } from "./KeywordResearchResults";
|
import { KeywordResearchResults } from "./KeywordResearchResults";
|
||||||
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
|
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
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 };
|
type KeywordSearchTab = SearchTab & { input: KeywordSearchTabInput };
|
||||||
|
|
||||||
function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
|
function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
|
||||||
@ -31,6 +40,11 @@ function isKeywordSearchTab(tab: SearchTab): tab is KeywordSearchTab {
|
|||||||
export function KeywordResearchPage(input: Props) {
|
export function KeywordResearchPage(input: Props) {
|
||||||
const setSearchParams = useKeywordSearchParams();
|
const setSearchParams = useKeywordSearchParams();
|
||||||
const projectId = input.projectId;
|
const projectId = input.projectId;
|
||||||
|
const { locationCode, displayedLocationCode, setPreferredLocationCode } =
|
||||||
|
useResolvedKeywordLocation({
|
||||||
|
projectId,
|
||||||
|
locationCode: input.locationCode,
|
||||||
|
});
|
||||||
|
|
||||||
const navigateToKeywordInput = useCallback(
|
const navigateToKeywordInput = useCallback(
|
||||||
(tabInput: KeywordSearchTabInput | null) => {
|
(tabInput: KeywordSearchTabInput | null) => {
|
||||||
@ -47,10 +61,7 @@ export function KeywordResearchPage(input: Props) {
|
|||||||
|
|
||||||
setSearchParams({
|
setSearchParams({
|
||||||
q: tabInput.keyword,
|
q: tabInput.keyword,
|
||||||
loc:
|
loc: tabInput.locationCode,
|
||||||
tabInput.locationCode === DEFAULT_LOCATION_CODE
|
|
||||||
? undefined
|
|
||||||
: tabInput.locationCode,
|
|
||||||
kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit,
|
kLimit: tabInput.resultLimit === 150 ? undefined : tabInput.resultLimit,
|
||||||
mode: tabInput.mode === "auto" ? undefined : tabInput.mode,
|
mode: tabInput.mode === "auto" ? undefined : tabInput.mode,
|
||||||
cs: tabInput.clickstream ? true : undefined,
|
cs: tabInput.clickstream ? true : undefined,
|
||||||
@ -66,7 +77,7 @@ export function KeywordResearchPage(input: Props) {
|
|||||||
return {
|
return {
|
||||||
type: "keyword",
|
type: "keyword",
|
||||||
keyword,
|
keyword,
|
||||||
locationCode: input.locationCode,
|
locationCode,
|
||||||
resultLimit: input.resultLimit,
|
resultLimit: input.resultLimit,
|
||||||
mode: input.keywordMode,
|
mode: input.keywordMode,
|
||||||
clickstream: input.clickstream,
|
clickstream: input.clickstream,
|
||||||
@ -75,7 +86,7 @@ export function KeywordResearchPage(input: Props) {
|
|||||||
input.clickstream,
|
input.clickstream,
|
||||||
input.keywordInput,
|
input.keywordInput,
|
||||||
input.keywordMode,
|
input.keywordMode,
|
||||||
input.locationCode,
|
locationCode,
|
||||||
input.resultLimit,
|
input.resultLimit,
|
||||||
]);
|
]);
|
||||||
const searchTabs = useSearchTabNavigation({
|
const searchTabs = useSearchTabNavigation({
|
||||||
@ -98,7 +109,13 @@ export function KeywordResearchPage(input: Props) {
|
|||||||
const tab = searchTabs.tabs.find(
|
const tab = searchTabs.tabs.find(
|
||||||
(candidate) => candidate.id === searchTabs.activeTabId,
|
(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]);
|
}, [searchTabs.activeTabId, searchTabs.tabs, urlInput]);
|
||||||
|
|
||||||
const onFormSubmit = useCallback(
|
const onFormSubmit = useCallback(
|
||||||
@ -148,14 +165,16 @@ export function KeywordResearchPage(input: Props) {
|
|||||||
[searchTabs.tabs],
|
[searchTabs.tabs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const controllerInput = useMemo<Props>(
|
const controllerInput = useMemo<ControllerProps>(
|
||||||
() =>
|
() =>
|
||||||
activeTab
|
activeTab
|
||||||
? {
|
? {
|
||||||
...input,
|
...input,
|
||||||
keywordInput: activeTab.input.keyword,
|
keywordInput: activeTab.input.keyword,
|
||||||
locationCode: activeTab.input.locationCode,
|
locationCode: activeTab.input.locationCode,
|
||||||
hasExplicitLocationCode: true,
|
displayedLocationCode:
|
||||||
|
activeTab.input.locationCode ?? displayedLocationCode,
|
||||||
|
setPreferredLocationCode,
|
||||||
resultLimit: activeTab.input.resultLimit,
|
resultLimit: activeTab.input.resultLimit,
|
||||||
keywordMode: activeTab.input.mode,
|
keywordMode: activeTab.input.mode,
|
||||||
clickstream: activeTab.input.clickstream,
|
clickstream: activeTab.input.clickstream,
|
||||||
@ -164,10 +183,21 @@ export function KeywordResearchPage(input: Props) {
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
...input,
|
...input,
|
||||||
|
locationCode,
|
||||||
|
displayedLocationCode,
|
||||||
|
setPreferredLocationCode,
|
||||||
getOpenKeywordTabs,
|
getOpenKeywordTabs,
|
||||||
keywordTabsLimit: searchTabs.limit,
|
keywordTabsLimit: searchTabs.limit,
|
||||||
},
|
},
|
||||||
[activeTab, getOpenKeywordTabs, input, searchTabs.limit],
|
[
|
||||||
|
activeTab,
|
||||||
|
getOpenKeywordTabs,
|
||||||
|
input,
|
||||||
|
displayedLocationCode,
|
||||||
|
locationCode,
|
||||||
|
searchTabs.limit,
|
||||||
|
setPreferredLocationCode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const controller = useKeywordResearchController({
|
const controller = useKeywordResearchController({
|
||||||
...controllerInput,
|
...controllerInput,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { toast } from "sonner";
|
|||||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { getLanguageCode } from "@/client/features/keywords/utils";
|
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
||||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||||
@ -62,7 +61,7 @@ export function parseKeywordInput(value: string) {
|
|||||||
*/
|
*/
|
||||||
export function buildKeywordSearchKey(params: {
|
export function buildKeywordSearchKey(params: {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
mode: KeywordMode;
|
mode: KeywordMode;
|
||||||
clickstream: boolean;
|
clickstream: boolean;
|
||||||
@ -127,7 +126,6 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
keywords: [...selectedRows],
|
keywords: [...selectedRows],
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: getLanguageCode(input.locationCode),
|
|
||||||
metrics,
|
metrics,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,22 +2,25 @@ import { useNavigate } from "@tanstack/react-router";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
|
import { usePreferredKeywordLocation } from "@/client/features/keywords/hooks/usePreferredKeywordLocation";
|
||||||
|
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
|
||||||
import { saveKeywords } from "@/serverFunctions/keywords";
|
import { saveKeywords } from "@/serverFunctions/keywords";
|
||||||
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
import type { SaveKeywordsInput } from "@/types/schemas/keywords";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { KeywordResearchControllerInput } from "./useKeywordResearchController";
|
|
||||||
|
|
||||||
export function useResolvedKeywordLocation(
|
export function useResolvedKeywordLocation(input: {
|
||||||
input: KeywordResearchControllerInput,
|
projectId: string;
|
||||||
) {
|
locationCode?: number;
|
||||||
const { preferredLocationCode, setPreferredLocationCode } =
|
}) {
|
||||||
usePreferredKeywordLocation();
|
const projectMarket = useProjectMarket(input.projectId);
|
||||||
const locationCode =
|
const {
|
||||||
!input.hasExplicitLocationCode && input.keywordInput === ""
|
preferredLocationCode,
|
||||||
? preferredLocationCode
|
selectedLocationCode,
|
||||||
: input.locationCode;
|
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) {
|
export function useKeywordUiState(initialShowFilters: boolean) {
|
||||||
|
|||||||
@ -25,13 +25,12 @@ import {
|
|||||||
useKeywordSaveMutation,
|
useKeywordSaveMutation,
|
||||||
useKeywordSearchParams,
|
useKeywordSearchParams,
|
||||||
useKeywordUiState,
|
useKeywordUiState,
|
||||||
useResolvedKeywordLocation,
|
|
||||||
} from "./keywordControllerInternals";
|
} from "./keywordControllerInternals";
|
||||||
import { useKeywordOverviewState } from "./useKeywordOverviewState";
|
import { useKeywordOverviewState } from "./useKeywordOverviewState";
|
||||||
|
|
||||||
type OpenKeywordTabInput = {
|
type OpenKeywordTabInput = {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
mode: KeywordMode;
|
mode: KeywordMode;
|
||||||
clickstream: boolean;
|
clickstream: boolean;
|
||||||
@ -40,8 +39,9 @@ type OpenKeywordTabInput = {
|
|||||||
export type KeywordResearchControllerInput = {
|
export type KeywordResearchControllerInput = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
keywordInput: string;
|
keywordInput: string;
|
||||||
locationCode: number;
|
locationCode: number | undefined;
|
||||||
hasExplicitLocationCode: boolean;
|
displayedLocationCode: number;
|
||||||
|
setPreferredLocationCode: (locationCode: number) => void;
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
keywordMode: KeywordMode;
|
keywordMode: KeywordMode;
|
||||||
clickstream: boolean;
|
clickstream: boolean;
|
||||||
@ -60,8 +60,8 @@ export type KeywordResearchControllerInput = {
|
|||||||
export function useKeywordResearchController(
|
export function useKeywordResearchController(
|
||||||
input: KeywordResearchControllerInput,
|
input: KeywordResearchControllerInput,
|
||||||
) {
|
) {
|
||||||
const { locationCode, setPreferredLocationCode } =
|
const { displayedLocationCode, locationCode, setPreferredLocationCode } =
|
||||||
useResolvedKeywordLocation(input);
|
input;
|
||||||
const {
|
const {
|
||||||
filtersForm,
|
filtersForm,
|
||||||
values: filterValues,
|
values: filterValues,
|
||||||
@ -115,6 +115,7 @@ export function useKeywordResearchController(
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
keywordInput: input.keywordInput,
|
keywordInput: input.keywordInput,
|
||||||
locationCode,
|
locationCode,
|
||||||
|
displayedLocationCode,
|
||||||
resultLimit: input.resultLimit,
|
resultLimit: input.resultLimit,
|
||||||
mode: input.keywordMode,
|
mode: input.keywordMode,
|
||||||
clickstream: input.clickstream,
|
clickstream: input.clickstream,
|
||||||
@ -148,7 +149,7 @@ export function useKeywordResearchController(
|
|||||||
const controlsForm = useKeywordControlsForm(
|
const controlsForm = useKeywordControlsForm(
|
||||||
{
|
{
|
||||||
...input,
|
...input,
|
||||||
locationCode,
|
locationCode: displayedLocationCode,
|
||||||
getOpenKeywordTabs: input.getOpenKeywordTabs,
|
getOpenKeywordTabs: input.getOpenKeywordTabs,
|
||||||
keywordTabsLimit: input.keywordTabsLimit,
|
keywordTabsLimit: input.keywordTabsLimit,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export { LOCATIONS, getLanguageCode } from "./locations";
|
export { LOCATIONS } from "./locations";
|
||||||
|
|
||||||
export function scoreTierClass(value: number | null): string {
|
export function scoreTierClass(value: number | null): string {
|
||||||
if (value == null) return "score-tier-na";
|
if (value == null) return "score-tier-na";
|
||||||
|
|||||||
@ -11,12 +11,14 @@ import {
|
|||||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
|
||||||
|
import type { ProjectMarket } from "@/client/features/projects/types";
|
||||||
import {
|
import {
|
||||||
getGscConnection,
|
getGscConnection,
|
||||||
listGscSites,
|
listGscSites,
|
||||||
setGscSite,
|
setGscSite,
|
||||||
} from "@/serverFunctions/gsc";
|
} from "@/serverFunctions/gsc";
|
||||||
import { getProjects } from "@/serverFunctions/projects";
|
import { getProjects, setProjectMarket } from "@/serverFunctions/projects";
|
||||||
|
|
||||||
const GRANT_STATUS_KEY = ["gscGrantStatus"];
|
const GRANT_STATUS_KEY = ["gscGrantStatus"];
|
||||||
|
|
||||||
@ -31,19 +33,66 @@ export function SearchConsoleOnboardingStep() {
|
|||||||
queryKey: ["projects"],
|
queryKey: ["projects"],
|
||||||
queryFn: () => getProjects(),
|
queryFn: () => getProjects(),
|
||||||
});
|
});
|
||||||
const projectId = projectsQuery.data?.[0]?.id;
|
const project = projectsQuery.data?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
<h2 className="text-lg font-semibold">
|
<div className="space-y-4">
|
||||||
Connect with Google Search Console now?
|
<h2 className="text-lg font-semibold">
|
||||||
</h2>
|
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">
|
<p className="text-xs leading-relaxed text-base-content/55">
|
||||||
For now, Search Console data flows through the OpenSEO MCP. We're
|
We'll use this country and language for keyword, SERP, and domain data
|
||||||
building it into the OpenSEO app soon too.
|
unless you pick a different one. You can change it in project settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,11 @@ import { toast } from "sonner";
|
|||||||
import { Modal } from "@/client/components/Modal";
|
import { Modal } from "@/client/components/Modal";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { setLastProjectId } from "@/client/lib/active-project";
|
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";
|
import { createProject } from "@/serverFunctions/projects";
|
||||||
|
|
||||||
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||||
@ -12,11 +17,19 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = React.useState("");
|
const [name, setName] = React.useState("");
|
||||||
const [domain, setDomain] = React.useState("");
|
const [domain, setDomain] = React.useState("");
|
||||||
|
const [market, setMarket] = React.useState({
|
||||||
|
locationCode: DEFAULT_LOCATION_CODE,
|
||||||
|
languageCode: getLanguageCode(DEFAULT_LOCATION_CODE),
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
createProject({
|
createProject({
|
||||||
data: { name: name.trim(), domain: domain.trim() || undefined },
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
domain: domain.trim() || undefined,
|
||||||
|
...market,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: async (created) => {
|
onSuccess: async (created) => {
|
||||||
setLastProjectId(created.id);
|
setLastProjectId(created.id);
|
||||||
@ -88,6 +101,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="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 { ChevronLeft } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
||||||
|
import { ProjectMarketFields } from "@/client/features/projects/ProjectMarketFields";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import {
|
import {
|
||||||
clearLastProjectId,
|
clearLastProjectId,
|
||||||
@ -69,6 +70,10 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = React.useState(project.name);
|
const [name, setName] = React.useState(project.name);
|
||||||
const [domain, setDomain] = React.useState(project.domain ?? "");
|
const [domain, setDomain] = React.useState(project.domain ?? "");
|
||||||
|
const [market, setMarket] = React.useState({
|
||||||
|
locationCode: project.locationCode,
|
||||||
|
languageCode: project.languageCode,
|
||||||
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@ -77,6 +82,7 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
|
|||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
domain: domain.trim() || undefined,
|
domain: domain.trim() || undefined,
|
||||||
|
...market,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@ -89,7 +95,9 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
|
|||||||
|
|
||||||
const isDirty =
|
const isDirty =
|
||||||
name.trim() !== project.name ||
|
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) => {
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -130,6 +138,14 @@ function GeneralSection({ project }: { project: ProjectSummary }) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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).
|
// Shape returned by the getProjects server function (a mapped project row).
|
||||||
export type ProjectSummary = {
|
export type ProjectSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
domain: string | null;
|
domain: string | null;
|
||||||
|
// Default market for the project's data calls.
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
createdAt: 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,
|
projectId,
|
||||||
domain,
|
domain,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode,
|
|
||||||
onDone,
|
onDone,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -163,16 +162,10 @@ export function KeywordSuggestionStep({
|
|||||||
// Ads keyword data (e.g. Iceland) have no ranking data to suggest from.
|
// Ads keyword data (e.g. Iceland) have no ranking data to suggest from.
|
||||||
const labsSupported = isLabsLocationCode(locationCode);
|
const labsSupported = isLabsLocationCode(locationCode);
|
||||||
const suggestionsQuery = useQuery({
|
const suggestionsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: ["domainKeywordSuggestions", projectId, domain, locationCode],
|
||||||
"domainKeywordSuggestions",
|
|
||||||
projectId,
|
|
||||||
domain,
|
|
||||||
locationCode,
|
|
||||||
languageCode,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getDomainKeywordSuggestions({
|
getDomainKeywordSuggestions({
|
||||||
data: { projectId, domain, locationCode, languageCode },
|
data: { projectId, domain, locationCode },
|
||||||
}),
|
}),
|
||||||
enabled: labsSupported,
|
enabled: labsSupported,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,12 +10,13 @@ import {
|
|||||||
estimateRankCheckCredits,
|
estimateRankCheckCredits,
|
||||||
} from "@/shared/rank-tracking";
|
} from "@/shared/rank-tracking";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
getLanguageCode,
|
getLanguageCode,
|
||||||
getLanguageOptions,
|
getLanguageOptions,
|
||||||
} from "@/client/features/keywords/locations";
|
} from "@/client/features/keywords/locations";
|
||||||
import { getIsoCountryCode } from "@/shared/keyword-locations";
|
import { getIsoCountryCode } from "@/shared/keyword-locations";
|
||||||
import { LocationSelect } from "@/client/components/LocationSelect";
|
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 { SearchTargetingField } from "./SearchTargetingField";
|
||||||
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
||||||
import { useSaveConfigMutations } from "./useSaveConfigMutations";
|
import { useSaveConfigMutations } from "./useSaveConfigMutations";
|
||||||
@ -35,6 +36,45 @@ export function RankTrackingConfigModal({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onConfigCreated,
|
onConfigCreated,
|
||||||
}: Props) {
|
}: 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 isEdit = !!existingConfig;
|
||||||
const [step, setStep] = useState<"config" | "keywords">("config");
|
const [step, setStep] = useState<"config" | "keywords">("config");
|
||||||
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
|
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
|
||||||
@ -42,11 +82,10 @@ export function RankTrackingConfigModal({
|
|||||||
existingConfig?.devices ?? "mobile",
|
existingConfig?.devices ?? "mobile",
|
||||||
);
|
);
|
||||||
const [locationCode, setLocationCode] = useState(
|
const [locationCode, setLocationCode] = useState(
|
||||||
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
existingConfig?.locationCode ?? initialMarket.locationCode,
|
||||||
);
|
);
|
||||||
const [languageCode, setLanguageCode] = useState(
|
const [languageCode, setLanguageCode] = useState(
|
||||||
existingConfig?.languageCode ??
|
existingConfig?.languageCode ?? initialMarket.languageCode,
|
||||||
getLanguageCode(existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE),
|
|
||||||
);
|
);
|
||||||
const languageOptions = useMemo(
|
const languageOptions = useMemo(
|
||||||
() => getLanguageOptions(locationCode),
|
() => getLanguageOptions(locationCode),
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
buildKeywordResearchRequest,
|
buildKeywordResearchRequest,
|
||||||
keywordResearchQueryFn,
|
keywordResearchQueryFn,
|
||||||
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||||
import { getLanguageCode } from "@/client/features/keywords/locations";
|
|
||||||
import { getBacklinksOverview } from "@/serverFunctions/backlinks";
|
import { getBacklinksOverview } from "@/serverFunctions/backlinks";
|
||||||
import { getDomainOverview } from "@/serverFunctions/domain";
|
import { getDomainOverview } from "@/serverFunctions/domain";
|
||||||
export type { SearchTab } from "./types";
|
export type { SearchTab } from "./types";
|
||||||
@ -187,7 +186,6 @@ function getSearchTabQueryConfig(
|
|||||||
if (tab.input.type === "domain") {
|
if (tab.input.type === "domain") {
|
||||||
const input = tab.input;
|
const input = tab.input;
|
||||||
const trimmedDomain = input.domain.trim();
|
const trimmedDomain = input.domain.trim();
|
||||||
const languageCode = getLanguageCode(input.locationCode);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: [
|
queryKey: [
|
||||||
@ -196,7 +194,6 @@ function getSearchTabQueryConfig(
|
|||||||
trimmedDomain,
|
trimmedDomain,
|
||||||
input.subdomains,
|
input.subdomains,
|
||||||
input.locationCode,
|
input.locationCode,
|
||||||
languageCode,
|
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getDomainOverview({
|
getDomainOverview({
|
||||||
@ -205,7 +202,6 @@ function getSearchTabQueryConfig(
|
|||||||
domain: trimmedDomain,
|
domain: trimmedDomain,
|
||||||
includeSubdomains: input.subdomains,
|
includeSubdomains: input.subdomains,
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
|
|||||||
@ -14,13 +14,13 @@ export type DomainSearchTabInput = {
|
|||||||
type: "domain";
|
type: "domain";
|
||||||
domain: string;
|
domain: string;
|
||||||
subdomains: boolean;
|
subdomains: boolean;
|
||||||
locationCode: number;
|
locationCode?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KeywordSearchTabInput = {
|
export type KeywordSearchTabInput = {
|
||||||
type: "keyword";
|
type: "keyword";
|
||||||
keyword: string;
|
keyword: string;
|
||||||
locationCode: number;
|
locationCode?: number;
|
||||||
resultLimit: ResultLimit;
|
resultLimit: ResultLimit;
|
||||||
mode: KeywordMode;
|
mode: KeywordMode;
|
||||||
clickstream: boolean;
|
clickstream: boolean;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ type UseSearchTabNavigationArgs = {
|
|||||||
navigateToInput: (input: SearchTabInput | null) => void;
|
navigateToInput: (input: SearchTabInput | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function tabInputKey(input: SearchTabInput | null) {
|
export function tabInputKey(input: SearchTabInput | null) {
|
||||||
return input ? JSON.stringify(input) : "";
|
return input ? JSON.stringify(input) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import {
|
|||||||
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||||
domainSearchSchema,
|
domainSearchSchema,
|
||||||
} from "@/types/schemas/domain";
|
} from "@/types/schemas/domain";
|
||||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
|
||||||
import { getDomainRouteState } from "@/client/features/domain/domainRouteState";
|
import { getDomainRouteState } from "@/client/features/domain/domainRouteState";
|
||||||
|
import { useProjectMarket } from "@/client/features/projects/useProjectMarket";
|
||||||
|
|
||||||
const DEFAULT_DOMAIN_SEARCH = {
|
const DEFAULT_DOMAIN_SEARCH = {
|
||||||
domain: "",
|
domain: "",
|
||||||
@ -17,7 +17,6 @@ const DEFAULT_DOMAIN_SEARCH = {
|
|||||||
sort: "traffic",
|
sort: "traffic",
|
||||||
order: undefined,
|
order: undefined,
|
||||||
tab: "keywords",
|
tab: "keywords",
|
||||||
loc: DEFAULT_LOCATION_CODE,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
size: DEFAULT_DOMAIN_KEYWORDS_PAGE_SIZE,
|
||||||
include: "",
|
include: "",
|
||||||
@ -52,7 +51,8 @@ function DomainOverviewRoute() {
|
|||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const routeState = getDomainRouteState(search);
|
const projectMarket = useProjectMarket(projectId);
|
||||||
|
const routeState = getDomainRouteState(search, projectMarket);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DomainOverviewPage
|
<DomainOverviewPage
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
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 { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
|
||||||
import {
|
import {
|
||||||
isResultLimit,
|
isResultLimit,
|
||||||
@ -31,20 +30,17 @@ function KeywordResearchPageRoute() {
|
|||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const {
|
const {
|
||||||
q: keywordInput = "",
|
q: keywordInput = "",
|
||||||
loc: rawLocationCode,
|
loc: locationCode,
|
||||||
kLimit: resultLimit = 150,
|
kLimit: resultLimit = 150,
|
||||||
mode: keywordMode = "auto",
|
mode: keywordMode = "auto",
|
||||||
sort: sortField = "searchVolume",
|
sort: sortField = "searchVolume",
|
||||||
order: sortDir = "desc",
|
order: sortDir = "desc",
|
||||||
} = search;
|
} = search;
|
||||||
const locationCode = rawLocationCode ?? DEFAULT_LOCATION_CODE;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeywordResearchPage
|
<KeywordResearchPage
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
keywordInput={keywordInput}
|
keywordInput={keywordInput}
|
||||||
locationCode={locationCode}
|
locationCode={locationCode}
|
||||||
hasExplicitLocationCode={search.loc != null}
|
|
||||||
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
|
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
|
||||||
keywordMode={normalizeKeywordMode(keywordMode)}
|
keywordMode={normalizeKeywordMode(keywordMode)}
|
||||||
clickstream={search.cs ?? false}
|
clickstream={search.cs ?? false}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/server/lib/r2-cache";
|
} from "@/server/lib/r2-cache";
|
||||||
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
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 { z } from "zod";
|
||||||
import { getKeywordDataProvider } from "@/shared/keyword-locations";
|
import { getKeywordDataProvider } from "@/shared/keyword-locations";
|
||||||
import { type EnrichedKeyword, normalizeKeyword } from "./helpers";
|
import { type EnrichedKeyword, normalizeKeyword } from "./helpers";
|
||||||
@ -93,7 +93,7 @@ const CACHE_VERSION = 3;
|
|||||||
|
|
||||||
async function fetchRowsFromSource(
|
async function fetchRowsFromSource(
|
||||||
source: KeywordSource,
|
source: KeywordSource,
|
||||||
input: ResearchKeywordsInput,
|
input: ResolvedResearchKeywordsInput,
|
||||||
seedKeyword: string,
|
seedKeyword: string,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
creditFeature?: CreditFeature,
|
creditFeature?: CreditFeature,
|
||||||
@ -113,7 +113,7 @@ async function fetchRowsFromSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAutoRows(
|
async function fetchAutoRows(
|
||||||
input: ResearchKeywordsInput,
|
input: ResolvedResearchKeywordsInput,
|
||||||
seedKeyword: string,
|
seedKeyword: string,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
creditFeature?: CreditFeature,
|
creditFeature?: CreditFeature,
|
||||||
@ -175,7 +175,7 @@ async function fetchAutoRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGoogleAdsRows(
|
async function fetchGoogleAdsRows(
|
||||||
input: ResearchKeywordsInput,
|
input: ResolvedResearchKeywordsInput,
|
||||||
seedKeyword: string,
|
seedKeyword: string,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
creditFeature?: CreditFeature,
|
creditFeature?: CreditFeature,
|
||||||
@ -211,7 +211,7 @@ async function fetchGoogleAdsRows(
|
|||||||
|
|
||||||
async function fetchManualRows(
|
async function fetchManualRows(
|
||||||
mode: Exclude<KeywordMode, "auto">,
|
mode: Exclude<KeywordMode, "auto">,
|
||||||
input: ResearchKeywordsInput,
|
input: ResolvedResearchKeywordsInput,
|
||||||
seedKeyword: string,
|
seedKeyword: string,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
creditFeature?: CreditFeature,
|
creditFeature?: CreditFeature,
|
||||||
@ -242,7 +242,7 @@ async function fetchManualRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildResearchCacheKey(
|
async function buildResearchCacheKey(
|
||||||
input: ResearchKeywordsInput,
|
input: ResolvedResearchKeywordsInput,
|
||||||
normalizedKeywords: string[],
|
normalizedKeywords: string[],
|
||||||
mode: KeywordMode,
|
mode: KeywordMode,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
@ -261,7 +261,10 @@ async function buildResearchCacheKey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
|
function persistRows(
|
||||||
|
input: ResolvedResearchKeywordsInput,
|
||||||
|
rows: EnrichedKeyword[],
|
||||||
|
) {
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
rows.map((row) =>
|
rows.map((row) =>
|
||||||
KeywordResearchRepository.upsertKeywordMetric({
|
KeywordResearchRepository.upsertKeywordMetric({
|
||||||
@ -283,7 +286,7 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function research(
|
export async function research(
|
||||||
input: ResearchKeywordsInput,
|
input: ResolvedResearchKeywordsInput,
|
||||||
billingCustomer: BillingCustomerContext,
|
billingCustomer: BillingCustomerContext,
|
||||||
creditFeature?: CreditFeature,
|
creditFeature?: CreditFeature,
|
||||||
): Promise<ResearchResult> {
|
): Promise<ResearchResult> {
|
||||||
@ -300,7 +303,7 @@ export async function research(
|
|||||||
// Labs source modes and clickstream refinement don't exist for
|
// Labs source modes and clickstream refinement don't exist for
|
||||||
// Google-Ads-served countries; collapse both so equivalent requests share
|
// Google-Ads-served countries; collapse both so equivalent requests share
|
||||||
// one cache entry.
|
// one cache entry.
|
||||||
const effectiveInput: ResearchKeywordsInput =
|
const effectiveInput: ResolvedResearchKeywordsInput =
|
||||||
provider === "google_ads"
|
provider === "google_ads"
|
||||||
? { ...input, mode: "auto", clickstream: false }
|
? { ...input, mode: "auto", clickstream: false }
|
||||||
: input;
|
: input;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type {
|
|||||||
ExportSavedKeywordsInput,
|
ExportSavedKeywordsInput,
|
||||||
GetSavedKeywordsInput,
|
GetSavedKeywordsInput,
|
||||||
RemoveSavedKeywordsInput,
|
RemoveSavedKeywordsInput,
|
||||||
SaveKeywordsInput,
|
ResolvedSaveKeywordsInput,
|
||||||
UpdateSavedKeywordTagInput,
|
UpdateSavedKeywordTagInput,
|
||||||
UpdateSavedKeywordTagsInput,
|
UpdateSavedKeywordTagsInput,
|
||||||
} from "@/types/schemas/keywords";
|
} from "@/types/schemas/keywords";
|
||||||
@ -31,7 +31,7 @@ function parseMonthlySearches(payload: string | null): MonthlySearch[] {
|
|||||||
return result.success ? result.data : [];
|
return result.success ? result.data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveKeywords(input: SaveKeywordsInput) {
|
export async function saveKeywords(input: ResolvedSaveKeywordsInput) {
|
||||||
const normalizedKeywords = [
|
const normalizedKeywords = [
|
||||||
...new Set(
|
...new Set(
|
||||||
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),
|
input.keywords.map(normalizeKeyword).filter((kw) => kw.length > 0),
|
||||||
|
|||||||
@ -61,11 +61,13 @@ async function createProject(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
domain?: string,
|
domain?: string,
|
||||||
|
// Omitted keeps the column defaults.
|
||||||
|
market?: { locationCode: number; languageCode: string },
|
||||||
) {
|
) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(projects)
|
.insert(projects)
|
||||||
.values({ id, organizationId, name, domain })
|
.values({ id, organizationId, name, domain, ...market })
|
||||||
.returning();
|
.returning();
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@ -73,15 +75,48 @@ async function createProject(
|
|||||||
async function updateProject(
|
async function updateProject(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
organizationId: 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
|
const [row] = await db
|
||||||
.update(projects)
|
.update(projects)
|
||||||
.set({ name: input.name, domain: input.domain ?? null })
|
.set({ name: input.name, domain: input.domain ?? null, ...input.market })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(projects.id, projectId),
|
eq(projects.id, projectId),
|
||||||
eq(projects.organizationId, organizationId),
|
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();
|
.returning();
|
||||||
@ -162,6 +197,7 @@ export const ProjectRepository = {
|
|||||||
getProjectById,
|
getProjectById,
|
||||||
createProject,
|
createProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
updateProjectMarket,
|
||||||
tryCreateDefaultProject,
|
tryCreateDefaultProject,
|
||||||
archiveProject,
|
archiveProject,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
listProjects,
|
listProjects,
|
||||||
listProjectsEnsuringOne,
|
listProjectsEnsuringOne,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
|
setProjectMarket,
|
||||||
updateProject,
|
updateProject,
|
||||||
} from "@/server/features/projects/services/projects";
|
} from "@/server/features/projects/services/projects";
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ export const ProjectService = {
|
|||||||
listProjectsEnsuringOne,
|
listProjectsEnsuringOne,
|
||||||
createProject,
|
createProject,
|
||||||
updateProject,
|
updateProject,
|
||||||
|
setProjectMarket,
|
||||||
archiveProject,
|
archiveProject,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
listArchivedProjects,
|
listArchivedProjects,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
archiveProject: vi.fn(),
|
archiveProject: vi.fn(),
|
||||||
restoreProject: vi.fn(),
|
restoreProject: vi.fn(),
|
||||||
countProjects: vi.fn(),
|
countProjects: vi.fn(),
|
||||||
|
updateProjectMarket: vi.fn(),
|
||||||
getProjectForOrganization: vi.fn(),
|
getProjectForOrganization: vi.fn(),
|
||||||
listProjects: vi.fn(),
|
listProjects: vi.fn(),
|
||||||
listArchivedProjects: vi.fn(),
|
listArchivedProjects: vi.fn(),
|
||||||
@ -89,9 +90,40 @@ describe("project service", () => {
|
|||||||
"org_1",
|
"org_1",
|
||||||
"Acme",
|
"Acme",
|
||||||
"acme.com",
|
"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 () => {
|
it("maps the reserved Default conflict to a friendly CONFLICT", async () => {
|
||||||
mocks.createProject.mockRejectedValue(
|
mocks.createProject.mockRejectedValue(
|
||||||
new Error(
|
new Error(
|
||||||
@ -107,6 +139,37 @@ describe("project service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("updateProject", () => {
|
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 () => {
|
it("returns the updated project", async () => {
|
||||||
mocks.updateProject.mockResolvedValue(namedProject);
|
mocks.updateProject.mockResolvedValue(namedProject);
|
||||||
const { updateProject } = await import("./projects");
|
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", () => {
|
describe("archiveProject", () => {
|
||||||
it("refuses to archive the org's only project", async () => {
|
it("refuses to archive the org's only project", async () => {
|
||||||
mocks.countProjects.mockResolvedValue(1);
|
mocks.countProjects.mockResolvedValue(1);
|
||||||
|
|||||||
@ -2,25 +2,50 @@ import type {
|
|||||||
ArchiveProjectInput,
|
ArchiveProjectInput,
|
||||||
CreateProjectInput,
|
CreateProjectInput,
|
||||||
RestoreProjectInput,
|
RestoreProjectInput,
|
||||||
|
SetProjectMarketInput,
|
||||||
UpdateProjectInput,
|
UpdateProjectInput,
|
||||||
} from "@/types/schemas/projects";
|
} from "@/types/schemas/projects";
|
||||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { assertLanguageForLocation } from "@/server/lib/market";
|
||||||
|
import { getLanguageCode } from "@/shared/keyword-locations";
|
||||||
|
|
||||||
function mapProject(project: {
|
function mapProject(project: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
domain: string | null;
|
domain: string | null;
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
domain: project.domain,
|
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,
|
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",
|
// The projects table's only unique index guards the auto-created ("Default",
|
||||||
// null) singleton. A UNIQUE violation while writing exactly that name/domain
|
// null) singleton. A UNIQUE violation while writing exactly that name/domain
|
||||||
// therefore means one already exists — gating on the input (not just the error
|
// therefore means one already exists — gating on the input (not just the error
|
||||||
@ -67,6 +92,7 @@ export async function createProject(
|
|||||||
organizationId,
|
organizationId,
|
||||||
input.name,
|
input.name,
|
||||||
input.domain,
|
input.domain,
|
||||||
|
resolveMarketInput(input),
|
||||||
);
|
);
|
||||||
return mapProject(row);
|
return mapProject(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -85,7 +111,11 @@ export async function updateProject(
|
|||||||
const row = await ProjectRepository.updateProject(
|
const row = await ProjectRepository.updateProject(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
organizationId,
|
organizationId,
|
||||||
{ name: input.name, domain: input.domain },
|
{
|
||||||
|
name: input.name,
|
||||||
|
domain: input.domain,
|
||||||
|
market: resolveMarketInput(input),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return mapProject(row);
|
return mapProject(row);
|
||||||
} catch (error) {
|
} 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(
|
export async function archiveProject(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
input: ArchiveProjectInput,
|
input: ArchiveProjectInput,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const archivedConfig = {
|
|||||||
|
|
||||||
const baseInput = {
|
const baseInput = {
|
||||||
projectId: "project_1",
|
projectId: "project_1",
|
||||||
|
projectMarket: { locationCode: 2704, languageCode: "vi" },
|
||||||
domain: "acme.com",
|
domain: "acme.com",
|
||||||
locationCode: 2840,
|
locationCode: 2840,
|
||||||
languageCode: "es",
|
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_KEYWORDS_PER_CONFIG,
|
||||||
MAX_CONFIGS_PER_PROJECT,
|
MAX_CONFIGS_PER_PROJECT,
|
||||||
} from "@/shared/rank-tracking";
|
} from "@/shared/rank-tracking";
|
||||||
|
import { resolveMarket } from "@/shared/keyword-locations";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config
|
// Config
|
||||||
@ -29,6 +30,7 @@ import {
|
|||||||
|
|
||||||
async function createConfig(input: {
|
async function createConfig(input: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectMarket: { locationCode: number; languageCode: string };
|
||||||
domain: string;
|
domain: string;
|
||||||
locationCode?: number;
|
locationCode?: number;
|
||||||
languageCode?: string;
|
languageCode?: string;
|
||||||
@ -39,7 +41,10 @@ async function createConfig(input: {
|
|||||||
}) {
|
}) {
|
||||||
const normalizedDomain = normalizeDomain(input.domain);
|
const normalizedDomain = normalizeDomain(input.domain);
|
||||||
|
|
||||||
const locationCode = input.locationCode ?? 2840;
|
const { locationCode, languageCode } = resolveMarket(
|
||||||
|
input,
|
||||||
|
input.projectMarket,
|
||||||
|
);
|
||||||
const scheduleInterval = input.scheduleInterval ?? "weekly";
|
const scheduleInterval = input.scheduleInterval ?? "weekly";
|
||||||
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
|
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
|
||||||
? computeNextCheckAt(scheduleInterval)
|
? computeNextCheckAt(scheduleInterval)
|
||||||
@ -82,7 +87,7 @@ async function createConfig(input: {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await RankTrackingRepository.updateConfig(existing.id, input.projectId, {
|
await RankTrackingRepository.updateConfig(existing.id, input.projectId, {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
languageCode: input.languageCode ?? "en",
|
languageCode,
|
||||||
devices: input.devices ?? "both",
|
devices: input.devices ?? "both",
|
||||||
serpDepth: input.serpDepth,
|
serpDepth: input.serpDepth,
|
||||||
scheduleInterval,
|
scheduleInterval,
|
||||||
@ -102,7 +107,7 @@ async function createConfig(input: {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
domain: normalizedDomain,
|
domain: normalizedDomain,
|
||||||
locationCode,
|
locationCode,
|
||||||
languageCode: input.languageCode ?? "en",
|
languageCode,
|
||||||
locationName,
|
locationName,
|
||||||
devices: input.devices ?? "both",
|
devices: input.devices ?? "both",
|
||||||
serpDepth: input.serpDepth,
|
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({
|
mocks.getProjectForOrganization.mockResolvedValue({
|
||||||
id: "project_123",
|
id: "project_123",
|
||||||
name: "Test",
|
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 { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
|
||||||
const handler = vi.fn().mockReturnValue("ok");
|
const handler = vi.fn().mockReturnValue("ok");
|
||||||
|
|
||||||
@ -90,6 +92,12 @@ describe("withMcpProjectAuth", () => {
|
|||||||
organizationId: "org_123",
|
organizationId: "org_123",
|
||||||
projectId: "project_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,
|
auth,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
billing: buildBillingCustomer(auth, projectId),
|
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 { z } from "zod";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { isSupportedLanguageCode } from "@/shared/keyword-locations";
|
||||||
import {
|
|
||||||
getKeywordDataProvider,
|
|
||||||
getLanguageOptions,
|
|
||||||
isSupportedLanguageCode,
|
|
||||||
} from "@/shared/keyword-locations";
|
|
||||||
|
|
||||||
export const DEFAULT_LOCATION_CODE = 2840;
|
export const DEFAULT_LOCATION_CODE = 2840;
|
||||||
export const DEFAULT_LANGUAGE_CODE = "en";
|
|
||||||
|
|
||||||
export const projectIdSchema = z
|
export const projectIdSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -21,52 +15,15 @@ export const locationCodeSchema = z
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.describe(
|
.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
|
export const languageCodeSchema = z
|
||||||
.string()
|
.string()
|
||||||
.refine(isSupportedLanguageCode, {
|
.refine(isSupportedLanguageCode, {
|
||||||
message:
|
message:
|
||||||
"Unsupported language code. Use a supported code such as 'en', 'es', 'de', or 'fr'.",
|
"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();
|
vi.resetModules();
|
||||||
mocks.createDataforseoClient.mockReset();
|
mocks.createDataforseoClient.mockReset();
|
||||||
mocks.getProjectForOrganization.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 () => {
|
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 ?? "") : "";
|
return first?.type === "text" ? (first.text ?? "") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usProjectRow = {
|
||||||
|
id: "project_1",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
};
|
||||||
|
|
||||||
describe("DataForSEO research MCP tools", () => {
|
describe("DataForSEO research MCP tools", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mocks.createDataforseoClient.mockReset();
|
mocks.createDataforseoClient.mockReset();
|
||||||
mocks.getProjectForOrganization.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 () => {
|
it("searches local businesses without running rankings or Q&A", async () => {
|
||||||
|
|||||||
@ -17,10 +17,10 @@ import {
|
|||||||
readPath,
|
readPath,
|
||||||
type McpTableColumn,
|
type McpTableColumn,
|
||||||
} from "@/server/mcp/table";
|
} from "@/server/mcp/table";
|
||||||
|
import { resolveLabsMarket, resolveMarket } from "@/shared/keyword-locations";
|
||||||
|
import { assertLanguageForLocation } from "@/server/lib/market";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
assertLanguageForLocation,
|
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -46,10 +46,14 @@ const marketSchema = z
|
|||||||
country: z
|
country: z
|
||||||
.enum(["US", "USA", "United States", "United States of America"])
|
.enum(["US", "USA", "United States", "United States of America"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Country selector. Only the United States is supported."),
|
.describe(
|
||||||
|
"Country selector. Only the United States can be selected explicitly.",
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.optional()
|
.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
|
const nearSchema = z
|
||||||
.object({
|
.object({
|
||||||
@ -344,10 +348,20 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
|
|||||||
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
|
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
|
||||||
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
|
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
|
||||||
|
|
||||||
function resolveMarketLocationCode(_market: Market | undefined): number {
|
/**
|
||||||
// The Zod enum on market.country already restricts values to United States
|
* Resolves the market selector to a Labs location + language. An explicit
|
||||||
// variants, so no other country can reach this code path.
|
* country wins; omitted inherits the project's default via resolveLabsMarket,
|
||||||
return DEFAULT_LOCATION_CODE;
|
* 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 {
|
function formatCoordinate(value: number): string {
|
||||||
@ -594,10 +608,11 @@ export const getRankedKeywordsTool = {
|
|||||||
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
|
handler: withMcpProjectAuth(async (args: GetRankedKeywordsArgs, context) => {
|
||||||
const client = createDataforseoClient(context.billing);
|
const client = createDataforseoClient(context.billing);
|
||||||
const targetIsPage = /^https?:\/\//.test(args.target);
|
const targetIsPage = /^https?:\/\//.test(args.target);
|
||||||
|
const market = resolveMarketSelector(args.market, context.project);
|
||||||
const keywords = await client.domain.rankedKeywords({
|
const keywords = await client.domain.rankedKeywords({
|
||||||
target: args.target,
|
target: args.target,
|
||||||
locationCode: resolveMarketLocationCode(args.market),
|
locationCode: market.locationCode,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: market.languageCode,
|
||||||
limit: args.limit ?? 50,
|
limit: args.limit ?? 50,
|
||||||
offset: args.offset,
|
offset: args.offset,
|
||||||
orderBy: sortOrderByRankedMode(args.sortBy),
|
orderBy: sortOrderByRankedMode(args.sortBy),
|
||||||
@ -693,7 +708,7 @@ export const getLocalSerpResultsTool = {
|
|||||||
const results = await client.serp.local({
|
const results = await client.serp.local({
|
||||||
keyword: args.keyword,
|
keyword: args.keyword,
|
||||||
locationCoordinate: formatLocalSerpCoordinate(args.near),
|
locationCoordinate: formatLocalSerpCoordinate(args.near),
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
languageCode: args.languageCode ?? context.project.languageCode,
|
||||||
searchType: args.searchType ?? "maps",
|
searchType: args.searchType ?? "maps",
|
||||||
device: args.device ?? "desktop",
|
device: args.device ?? "desktop",
|
||||||
depth: args.depth ?? 20,
|
depth: args.depth ?? 20,
|
||||||
@ -736,7 +751,7 @@ export const getGoogleBusinessQuestionsTool = {
|
|||||||
const questions = await client.business.questionsAnswers({
|
const questions = await client.business.questionsAnswers({
|
||||||
keyword: args.keyword,
|
keyword: args.keyword,
|
||||||
locationCoordinate: formatQuestionsAnswersCoordinate(args.near),
|
locationCoordinate: formatQuestionsAnswersCoordinate(args.near),
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
languageCode: args.languageCode ?? context.project.languageCode,
|
||||||
depth: args.depth ?? 20,
|
depth: args.depth ?? 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -773,10 +788,11 @@ export const findSerpCompetitorsTool = {
|
|||||||
handler: withMcpProjectAuth(
|
handler: withMcpProjectAuth(
|
||||||
async (args: FindSerpCompetitorsArgs, context) => {
|
async (args: FindSerpCompetitorsArgs, context) => {
|
||||||
const client = createDataforseoClient(context.billing);
|
const client = createDataforseoClient(context.billing);
|
||||||
|
const market = resolveMarketSelector(args.market, context.project);
|
||||||
const competitors = await client.labs.serpCompetitors({
|
const competitors = await client.labs.serpCompetitors({
|
||||||
keywords: args.keywords,
|
keywords: args.keywords,
|
||||||
locationCode: resolveMarketLocationCode(args.market),
|
locationCode: market.locationCode,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: market.languageCode,
|
||||||
itemTypes: args.resultTypes ?? ["organic", "local_pack"],
|
itemTypes: args.resultTypes ?? ["organic", "local_pack"],
|
||||||
includeSubdomains: args.includeSubdomains,
|
includeSubdomains: args.includeSubdomains,
|
||||||
limit: args.limit ?? 50,
|
limit: args.limit ?? 50,
|
||||||
@ -829,10 +845,11 @@ export const getKeywordMetricsTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
|
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 client = createDataforseoClient(context.billing);
|
||||||
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
|
|
||||||
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
|
|
||||||
const metrics = await fetchKeywordMetricsForList(client, {
|
const metrics = await fetchKeywordMetricsForList(client, {
|
||||||
keywords: args.keywords,
|
keywords: args.keywords,
|
||||||
locationCode,
|
locationCode,
|
||||||
|
|||||||
@ -7,16 +7,17 @@ import {
|
|||||||
optionalMetaOutputSchema,
|
optionalMetaOutputSchema,
|
||||||
} from "@/server/mcp/output-schemas";
|
} from "@/server/mcp/output-schemas";
|
||||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { resolveLabsMarket } from "@/shared/keyword-locations";
|
||||||
import {
|
import {
|
||||||
formatMcpTable,
|
formatMcpTable,
|
||||||
readPath,
|
readPath,
|
||||||
type McpTableColumn,
|
type McpTableColumn,
|
||||||
} from "@/server/mcp/table";
|
} from "@/server/mcp/table";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
assertLabsLocationCode,
|
assertLabsLocationCode,
|
||||||
assertLanguageForLocation,
|
assertLanguageForLocation,
|
||||||
|
} from "@/server/lib/market";
|
||||||
|
import {
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -59,13 +60,17 @@ export const getDomainKeywordSuggestionsTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
assertLabsLocationCode(args.locationCode);
|
const { locationCode, languageCode } = resolveLabsMarket(
|
||||||
assertLanguageForLocation(args.locationCode, args.languageCode);
|
args,
|
||||||
|
context.project,
|
||||||
|
);
|
||||||
|
assertLabsLocationCode(locationCode);
|
||||||
|
assertLanguageForLocation(locationCode, languageCode);
|
||||||
const keywords = await DomainService.getSuggestedKeywords(
|
const keywords = await DomainService.getSuggestedKeywords(
|
||||||
{
|
{
|
||||||
domain: args.domain,
|
domain: args.domain,
|
||||||
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
|
locationCode,
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
languageCode,
|
||||||
organizationId: context.auth.organizationId,
|
organizationId: context.auth.organizationId,
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,11 +4,12 @@ import { mcpResponse } from "@/server/mcp/formatters";
|
|||||||
import { buildProjectMeta } from "@/server/mcp/context";
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
||||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { resolveLabsMarket } from "@/shared/keyword-locations";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
assertLabsLocationCode,
|
assertLabsLocationCode,
|
||||||
assertLanguageForLocation,
|
assertLanguageForLocation,
|
||||||
|
} from "@/server/lib/market";
|
||||||
|
import {
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -52,15 +53,19 @@ export const getDomainOverviewTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
assertLabsLocationCode(args.locationCode);
|
const { locationCode, languageCode } = resolveLabsMarket(
|
||||||
assertLanguageForLocation(args.locationCode, args.languageCode);
|
args,
|
||||||
|
context.project,
|
||||||
|
);
|
||||||
|
assertLabsLocationCode(locationCode);
|
||||||
|
assertLanguageForLocation(locationCode, languageCode);
|
||||||
const result = await DomainService.getOverview(
|
const result = await DomainService.getOverview(
|
||||||
{
|
{
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
domain: args.domain,
|
domain: args.domain,
|
||||||
includeSubdomains: args.includeSubdomains,
|
includeSubdomains: args.includeSubdomains,
|
||||||
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
|
locationCode,
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
languageCode,
|
||||||
},
|
},
|
||||||
context.billing,
|
context.billing,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,10 +4,9 @@ import { mcpResponse } from "@/server/mcp/formatters";
|
|||||||
import { buildProjectMeta } from "@/server/mcp/context";
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
||||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { resolveMarket } from "@/shared/keyword-locations";
|
||||||
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
|
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -95,14 +94,12 @@ export const getSerpResultsTool = {
|
|||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
const client = createDataforseoClient(context.billing);
|
const client = createDataforseoClient(context.billing);
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
args.queries.map(async (q) => {
|
args.queries.map(async (q) => {
|
||||||
try {
|
try {
|
||||||
const items = await client.serp.live({
|
const items = await client.serp.live({
|
||||||
keyword: q.keyword,
|
keyword: q.keyword,
|
||||||
locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE,
|
...resolveMarket(q, context.project),
|
||||||
languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
|
||||||
});
|
});
|
||||||
// Trim noise — return only essentials per item.
|
// Trim noise — return only essentials per item.
|
||||||
const trimmed = items.slice(0, 20).map((item) => ({
|
const trimmed = items.slice(0, 20).map((item) => ({
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export const listProjectsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "List projects",
|
title: "List projects",
|
||||||
description:
|
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>,
|
inputSchema: {} as Record<string, never>,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
projects: z.array(
|
projects: z.array(
|
||||||
@ -22,6 +22,8 @@ export const listProjectsTool = {
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
domain: z.string().nullable().optional(),
|
domain: z.string().nullable().optional(),
|
||||||
|
locationCode: z.number(),
|
||||||
|
languageCode: z.string(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
@ -41,7 +43,8 @@ export const listProjectsTool = {
|
|||||||
projects.length === 0
|
projects.length === 0
|
||||||
? ["No projects yet. Create one in the dashboard."]
|
? ["No projects yet. Create one in the dashboard."]
|
||||||
: projects.map(
|
: 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({
|
return mcpResponse({
|
||||||
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
|
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
|
||||||
@ -54,6 +57,8 @@ export const listProjectsTool = {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
domain: p.domain,
|
domain: p.domain,
|
||||||
|
locationCode: p.locationCode,
|
||||||
|
languageCode: p.languageCode,
|
||||||
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
|
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -99,7 +99,11 @@ const backlinkPage = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.getProjectForOrganization.mockReset();
|
mocks.getProjectForOrganization.mockReset();
|
||||||
mocks.profileBacklinksPage.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", () => {
|
describe("DataForSEO research tool output schemas", () => {
|
||||||
|
|||||||
@ -7,11 +7,10 @@ import {
|
|||||||
optionalMetaOutputSchema,
|
optionalMetaOutputSchema,
|
||||||
} from "@/server/mcp/output-schemas";
|
} from "@/server/mcp/output-schemas";
|
||||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { resolveMarket } from "@/shared/keyword-locations";
|
||||||
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
|
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
|
||||||
|
import { assertLanguageForLocation } from "@/server/lib/market";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
assertLanguageForLocation,
|
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -108,13 +107,17 @@ export const researchKeywordsTool = {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
args.seeds.map(async (item) => {
|
args.seeds.map(async (item) => {
|
||||||
try {
|
try {
|
||||||
assertLanguageForLocation(item.locationCode, item.languageCode);
|
const { locationCode, languageCode } = resolveMarket(
|
||||||
|
item,
|
||||||
|
context.project,
|
||||||
|
);
|
||||||
|
assertLanguageForLocation(locationCode, languageCode);
|
||||||
const data = await KeywordResearchService.research(
|
const data = await KeywordResearchService.research(
|
||||||
{
|
{
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
keywords: [item.seed],
|
keywords: [item.seed],
|
||||||
locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE,
|
locationCode,
|
||||||
languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
languageCode,
|
||||||
resultLimit: args.resultLimit ?? 150,
|
resultLimit: args.resultLimit ?? 150,
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
clickstream: args.includeClickstreamData ?? false,
|
clickstream: args.includeClickstreamData ?? false,
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import { mcpResponse } from "@/server/mcp/formatters";
|
|||||||
import { buildProjectMeta } from "@/server/mcp/context";
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
||||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { resolveMarket } from "@/shared/keyword-locations";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
|
||||||
DEFAULT_LOCATION_CODE,
|
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
locationCodeSchema,
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
@ -66,8 +65,7 @@ export const saveKeywordsTool = {
|
|||||||
throw new Error("Replacement tags are required when tagMode is replace.");
|
throw new Error("Replacement tags are required when tagMode is replace.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
|
const { locationCode, languageCode } = resolveMarket(args, context.project);
|
||||||
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
|
|
||||||
|
|
||||||
await KeywordResearchService.saveKeywords({
|
await KeywordResearchService.saveKeywords({
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
|
|||||||
@ -51,7 +51,11 @@ describe("saved keyword MCP tools", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mocks.getProjectForOrganization.mockReset();
|
mocks.getProjectForOrganization.mockReset();
|
||||||
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
mocks.getProjectForOrganization.mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
mocks.getSavedKeywords.mockReset();
|
mocks.getSavedKeywords.mockReset();
|
||||||
mocks.saveKeywords.mockReset();
|
mocks.saveKeywords.mockReset();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -76,7 +76,11 @@ const toolExtra: ToolExtra = {
|
|||||||
describe("search console MCP tools", () => {
|
describe("search console MCP tools", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.getProjectForOrganization.mockReset();
|
mocks.getProjectForOrganization.mockReset();
|
||||||
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
mocks.getProjectForOrganization.mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
mocks.isHostedServerAuthMode.mockReset();
|
mocks.isHostedServerAuthMode.mockReset();
|
||||||
mocks.isHostedServerAuthMode.mockResolvedValue(true);
|
mocks.isHostedServerAuthMode.mockResolvedValue(true);
|
||||||
mocks.hasSelfHostedGscConfig.mockReset();
|
mocks.hasSelfHostedGscConfig.mockReset();
|
||||||
|
|||||||
@ -90,7 +90,11 @@ describe("MCP tool text output (service-backed tools)", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
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 () => {
|
it("research_keywords renders every keyword row in the text table", async () => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
domainPagesPageRequestSchema,
|
domainPagesPageRequestSchema,
|
||||||
} from "@/types/schemas/domain";
|
} from "@/types/schemas/domain";
|
||||||
import { DomainService } from "@/server/features/domain/services/DomainService";
|
import { DomainService } from "@/server/features/domain/services/DomainService";
|
||||||
|
import { resolveLabsMarket } from "@/shared/keyword-locations";
|
||||||
|
|
||||||
function shouldUseDomainE2eFixtures() {
|
function shouldUseDomainE2eFixtures() {
|
||||||
return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1";
|
return import.meta.env.VITE_E2E_DOMAIN_FIXTURES === "1";
|
||||||
@ -20,18 +21,17 @@ export const getDomainOverview = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(domainOverviewSchema)
|
.validator(domainOverviewSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
const input = {
|
||||||
|
...data,
|
||||||
|
...resolveLabsMarket(data, context.project),
|
||||||
|
projectId: context.projectId,
|
||||||
|
};
|
||||||
if (shouldUseDomainE2eFixtures()) {
|
if (shouldUseDomainE2eFixtures()) {
|
||||||
const fixtures = await getDomainE2eFixtures();
|
const fixtures = await getDomainE2eFixtures();
|
||||||
return fixtures.getFixtureOverview(data.domain);
|
return fixtures.getFixtureOverview(input.domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DomainService.getOverview(
|
return DomainService.getOverview(input, context);
|
||||||
{
|
|
||||||
...data,
|
|
||||||
projectId: context.projectId,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
|
export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
|
||||||
@ -41,6 +41,7 @@ export const getDomainKeywordSuggestions = createServerFn({ method: "POST" })
|
|||||||
DomainService.getSuggestedKeywords(
|
DomainService.getSuggestedKeywords(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
|
...resolveLabsMarket(data, context.project),
|
||||||
organizationId: context.organizationId,
|
organizationId: context.organizationId,
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
},
|
},
|
||||||
@ -52,34 +53,32 @@ export const getDomainKeywordsPage = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(domainKeywordsPageRequestSchema)
|
.validator(domainKeywordsPageRequestSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
const input = {
|
||||||
|
...data,
|
||||||
|
...resolveLabsMarket(data, context.project),
|
||||||
|
projectId: context.projectId,
|
||||||
|
};
|
||||||
if (shouldUseDomainE2eFixtures()) {
|
if (shouldUseDomainE2eFixtures()) {
|
||||||
const fixtures = await getDomainE2eFixtures();
|
const fixtures = await getDomainE2eFixtures();
|
||||||
return fixtures.getFixtureKeywordsPage(data);
|
return fixtures.getFixtureKeywordsPage(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DomainService.getKeywordsPage(
|
return DomainService.getKeywordsPage(input, context);
|
||||||
{
|
|
||||||
...data,
|
|
||||||
projectId: context.projectId,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDomainPagesPage = createServerFn({ method: "POST" })
|
export const getDomainPagesPage = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(domainPagesPageRequestSchema)
|
.validator(domainPagesPageRequestSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
const input = {
|
||||||
|
...data,
|
||||||
|
...resolveLabsMarket(data, context.project),
|
||||||
|
projectId: context.projectId,
|
||||||
|
};
|
||||||
if (shouldUseDomainE2eFixtures()) {
|
if (shouldUseDomainE2eFixtures()) {
|
||||||
const fixtures = await getDomainE2eFixtures();
|
const fixtures = await getDomainE2eFixtures();
|
||||||
return fixtures.getFixturePagesPage(data);
|
return fixtures.getFixturePagesPage(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DomainService.getPagesPage(
|
return DomainService.getPagesPage(input, context);
|
||||||
{
|
|
||||||
...data,
|
|
||||||
projectId: context.projectId,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/types/schemas/keywords";
|
} from "@/types/schemas/keywords";
|
||||||
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
|
import { resolveMarket } from "@/shared/keyword-locations";
|
||||||
|
|
||||||
function shouldUseKeywordE2eFixtures() {
|
function shouldUseKeywordE2eFixtures() {
|
||||||
return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1";
|
return import.meta.env.VITE_E2E_KEYWORD_FIXTURES === "1";
|
||||||
@ -26,18 +27,17 @@ export const researchKeywords = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(researchKeywordsSchema)
|
.validator(researchKeywordsSchema)
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
|
const input = {
|
||||||
|
...data,
|
||||||
|
...resolveMarket(data, context.project),
|
||||||
|
projectId: context.projectId,
|
||||||
|
};
|
||||||
if (shouldUseKeywordE2eFixtures()) {
|
if (shouldUseKeywordE2eFixtures()) {
|
||||||
const fixtures = await getKeywordE2eFixtures();
|
const fixtures = await getKeywordE2eFixtures();
|
||||||
return fixtures.getKeywordResearchFixture(data);
|
return fixtures.getKeywordResearchFixture(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
return KeywordResearchService.research(
|
return KeywordResearchService.research(input, context);
|
||||||
{
|
|
||||||
...data,
|
|
||||||
projectId: context.projectId,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveKeywords = createServerFn({ method: "POST" })
|
export const saveKeywords = createServerFn({ method: "POST" })
|
||||||
@ -46,6 +46,7 @@ export const saveKeywords = createServerFn({ method: "POST" })
|
|||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return KeywordResearchService.saveKeywords({
|
return KeywordResearchService.saveKeywords({
|
||||||
...data,
|
...data,
|
||||||
|
...resolveMarket(data, context.project),
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -126,6 +127,7 @@ export const getSerpAnalysis = createServerFn({ method: "POST" })
|
|||||||
KeywordResearchService.getSerpAnalysis(
|
KeywordResearchService.getSerpAnalysis(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
|
...resolveMarket(data, context.project),
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
archiveProjectSchema,
|
archiveProjectSchema,
|
||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
restoreProjectSchema,
|
restoreProjectSchema,
|
||||||
|
setProjectMarketSchema,
|
||||||
updateProjectSchema,
|
updateProjectSchema,
|
||||||
} from "@/types/schemas/projects";
|
} from "@/types/schemas/projects";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -34,6 +35,13 @@ export const updateProject = createServerFn({ method: "POST" })
|
|||||||
ProjectService.updateProject(context.organizationId, data),
|
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" })
|
export const archiveProject = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(archiveProjectSchema)
|
.validator(archiveProjectSchema)
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export const createRankTrackingConfig = createServerFn({ method: "POST" })
|
|||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
const result = await RankTrackingService.createConfig({
|
const result = await RankTrackingService.createConfig({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
|
projectMarket: context.project,
|
||||||
domain: data.domain,
|
domain: data.domain,
|
||||||
locationCode: data.locationCode,
|
locationCode: data.locationCode,
|
||||||
languageCode: data.languageCode,
|
languageCode: data.languageCode,
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
isLabsLocationCode,
|
isLabsLocationCode,
|
||||||
isSupportedLanguageCode,
|
isSupportedLanguageCode,
|
||||||
isSupportedLocationCode,
|
isSupportedLocationCode,
|
||||||
|
resolveLabsMarket,
|
||||||
|
resolveMarket,
|
||||||
} from "./keyword-locations";
|
} from "./keyword-locations";
|
||||||
|
|
||||||
describe("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";
|
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
|
* Language codes DataForSEO accepts — the master LANGUAGE_OPTIONS list. Callers
|
||||||
* (e.g. MCP tools) can pass an arbitrary `language_code`; an unsupported one is
|
* (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
|
* Languages to offer for a location. Restricts the global LANGUAGE_OPTIONS
|
||||||
* global LANGUAGE_OPTIONS list to the languages DataForSEO supports for that
|
* list to the languages DataForSEO supports for that country, so a picker
|
||||||
* country, so the picker isn't a wall of irrelevant options.
|
* isn't a wall of irrelevant options.
|
||||||
*/
|
*/
|
||||||
export function getLanguageOptions(
|
export function getLanguageOptions(
|
||||||
locationCode: number,
|
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(),
|
projectId: z.string().uuid(),
|
||||||
domain: z.string().min(1, "Domain is required").max(255),
|
domain: z.string().min(1, "Domain is required").max(255),
|
||||||
includeSubdomains: z.boolean().default(true),
|
includeSubdomains: z.boolean().default(true),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@ -74,8 +74,8 @@ const domainTabs = ["keywords", "pages"] as const;
|
|||||||
export const domainKeywordSuggestionsSchema = z.object({
|
export const domainKeywordSuggestionsSchema = z.object({
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
domain: domainField,
|
domain: domainField,
|
||||||
locationCode: z.number().int().positive(),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8),
|
languageCode: z.string().min(2).max(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const;
|
export const DOMAIN_KEYWORDS_PAGE_SIZES = [50, 100, 200] as const;
|
||||||
@ -119,8 +119,8 @@ export const domainKeywordsPageRequestSchema = z.object({
|
|||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
domain: z.string().min(1).max(255),
|
domain: z.string().min(1).max(255),
|
||||||
includeSubdomains: z.boolean().default(true),
|
includeSubdomains: z.boolean().default(true),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).optional(),
|
||||||
page: z.number().int().positive().default(1),
|
page: z.number().int().positive().default(1),
|
||||||
pageSize: z
|
pageSize: z
|
||||||
.number()
|
.number()
|
||||||
@ -141,8 +141,8 @@ export const domainPagesPageRequestSchema = z.object({
|
|||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
domain: z.string().min(1).max(255),
|
domain: z.string().min(1).max(255),
|
||||||
includeSubdomains: z.boolean().default(true),
|
includeSubdomains: z.boolean().default(true),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).optional(),
|
||||||
page: z.number().int().positive().default(1),
|
page: z.number().int().positive().default(1),
|
||||||
pageSize: z
|
pageSize: z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@ -18,8 +18,8 @@ const sortDirs = ["asc", "desc"] as const;
|
|||||||
export const researchKeywordsSchema = z.object({
|
export const researchKeywordsSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
keywords: z.array(z.string().min(1)).min(1).max(200),
|
keywords: z.array(z.string().min(1)).min(1).max(200),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).optional(),
|
||||||
resultLimit: z
|
resultLimit: z
|
||||||
.union([z.literal(150), z.literal(300), z.literal(500)])
|
.union([z.literal(150), z.literal(300), z.literal(500)])
|
||||||
.default(150),
|
.default(150),
|
||||||
@ -35,8 +35,8 @@ export const saveKeywordsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
keywords: z.array(z.string().min(1)).min(1).max(500),
|
keywords: z.array(z.string().min(1)).min(1).max(500),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).optional(),
|
||||||
tags: z.array(savedKeywordTagSchema).max(20).optional(),
|
tags: z.array(savedKeywordTagSchema).max(20).optional(),
|
||||||
tagMode: z.enum(["append", "replace"]).optional(),
|
tagMode: z.enum(["append", "replace"]).optional(),
|
||||||
metrics: z
|
metrics: z
|
||||||
@ -149,6 +149,17 @@ export const refreshSavedKeywordMetricsSchema = z.object({
|
|||||||
|
|
||||||
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
|
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
|
||||||
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
|
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<
|
export type RemoveSavedKeywordsInput = z.infer<
|
||||||
typeof removeSavedKeywordsSchema
|
typeof removeSavedKeywordsSchema
|
||||||
>;
|
>;
|
||||||
@ -172,8 +183,8 @@ export type RefreshSavedKeywordMetricsInput = z.infer<
|
|||||||
export const serpAnalysisSchema = z.object({
|
export const serpAnalysisSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
keyword: z.string().min(1),
|
keyword: z.string().min(1),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
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 { z } from "zod";
|
||||||
|
import {
|
||||||
|
isSupportedLanguageCode,
|
||||||
|
isSupportedLocationCode,
|
||||||
|
} from "@/shared/keyword-locations";
|
||||||
|
|
||||||
const projectNameField = z
|
const projectNameField = z
|
||||||
.string()
|
.string()
|
||||||
@ -13,15 +17,63 @@ const projectDomainField = z
|
|||||||
.transform((value) => value || undefined)
|
.transform((value) => value || undefined)
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
export const createProjectSchema = z.object({
|
// Default market for the project's data calls. The location/language PAIR is
|
||||||
name: projectNameField,
|
// validated in the service (an update may change one side and needs the
|
||||||
domain: projectDomainField,
|
// 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),
|
projectId: z.string().min(1),
|
||||||
name: projectNameField,
|
locationCode: z
|
||||||
domain: projectDomainField,
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine(isSupportedLocationCode, "Unsupported DataForSEO location code"),
|
||||||
|
languageCode: z
|
||||||
|
.string()
|
||||||
|
.refine(isSupportedLanguageCode, "Unsupported language code"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const archiveProjectSchema = z.object({
|
export const archiveProjectSchema = z.object({
|
||||||
@ -37,5 +89,6 @@ export const restoreProjectSchema = z.object({
|
|||||||
|
|
||||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||||
|
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
|
||||||
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
|
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
|
||||||
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;
|
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user