feat: support all 94 DataForSEO countries across features (#127)

This commit is contained in:
Ben Senescu 2026-04-20 15:23:25 -04:00 committed by GitHub
parent 021ec830af
commit ed65d6976c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 281 additions and 55 deletions

View File

@ -2,7 +2,7 @@
"name": "open-seo", "name": "open-seo",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"version": "0.0.6", "version": "0.0.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "AUTH_MODE=local_noauth vite dev", "dev": "AUTH_MODE=local_noauth vite dev",

9
release-notes/v0.0.7.md Normal file
View File

@ -0,0 +1,9 @@
This release expands country coverage and adds bulk delete for saved keywords.
## Added
- Support all countries tracked by DataForSEO.
- Add country selector to Domain Overview Page
- Improve bulk delete of saved keywords.
Full Changelog: https://github.com/every-app/open-seo/compare/v0.0.6...v0.0.7

View File

@ -25,6 +25,7 @@ type Props = {
order?: SortOrder; order?: SortOrder;
tab: DomainActiveTab; tab: DomainActiveTab;
search: string; search: string;
locationCode: number;
}; };
navigate: (args: { navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>; search: (prev: Record<string, unknown>) => Record<string, unknown>;
@ -69,6 +70,9 @@ export function DomainOverviewPage({
onSortChange={(sort) => onSortChange={(sort) =>
state.applySort(sort, getDefaultSortOrder(sort)) state.applySort(sort, getDefaultSortOrder(sort))
} }
onLocationChange={(locationCode) =>
state.applyLocationChange(locationCode)
}
/> />
{state.isLoading ? ( {state.isLoading ? (
@ -148,6 +152,7 @@ export function DomainOverviewPage({
}} }}
onSearchChange={state.setPendingSearch} onSearchChange={state.setPendingSearch}
onSaveKeywords={state.handleSaveKeywords} onSaveKeywords={state.handleSaveKeywords}
canSaveKeywords={state.canSaveKeywords}
onSortClick={state.handleSortColumnClick} onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection} onToggleKeyword={state.toggleKeywordSelection}
onToggleAllVisible={state.toggleAllVisibleKeywords} onToggleAllVisible={state.toggleAllVisibleKeywords}

View File

@ -46,6 +46,7 @@ type Props = {
onTabChange: (tab: DomainActiveTab) => void; onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSaveKeywords: () => void; onSaveKeywords: () => void;
canSaveKeywords: boolean;
onSortClick: (sort: DomainSortMode) => void; onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void; onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => void; onToggleAllVisible: () => void;
@ -69,6 +70,7 @@ export function DomainResultsCard({
onTabChange, onTabChange,
onSearchChange, onSearchChange,
onSaveKeywords, onSaveKeywords,
canSaveKeywords,
onSortClick, onSortClick,
onToggleKeyword, onToggleKeyword,
onToggleAllVisible, onToggleAllVisible,
@ -124,7 +126,12 @@ export function DomainResultsCard({
<button <button
className="btn btn-sm" className="btn btn-sm"
onClick={onSaveKeywords} onClick={onSaveKeywords}
disabled={selectedKeywords.size === 0} disabled={selectedKeywords.size === 0 || !canSaveKeywords}
title={
!canSaveKeywords && selectedKeywords.size > 0
? "Re-run search to save keywords for the selected location"
: undefined
}
> >
<Save className="size-4" /> Save Keywords <Save className="size-4" /> Save Keywords
</button> </button>

View File

@ -4,12 +4,14 @@ import { getFieldError, getFormError } from "@/client/lib/forms";
import type { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController"; import type { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
import { toSortMode } from "@/client/features/domain/utils"; import { toSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode } from "@/client/features/domain/types"; import type { DomainSortMode } from "@/client/features/domain/types";
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
type Props = { type Props = {
controlsForm: ReturnType<typeof useDomainOverviewController>["controlsForm"]; controlsForm: ReturnType<typeof useDomainOverviewController>["controlsForm"];
isLoading: boolean; isLoading: boolean;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
onSortChange: (sort: DomainSortMode) => void; onSortChange: (sort: DomainSortMode) => void;
onLocationChange: (locationCode: number) => void;
}; };
export function DomainSearchCard({ export function DomainSearchCard({
@ -17,6 +19,7 @@ export function DomainSearchCard({
isLoading, isLoading,
onSubmit, onSubmit,
onSortChange, onSortChange,
onLocationChange,
}: Props) { }: Props) {
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
@ -31,7 +34,7 @@ export function DomainSearchCard({
return ( return (
<label <label
className={`input input-bordered lg:col-span-8 flex items-center gap-2 ${domainError ? "input-error" : ""}`} className={`input input-bordered lg:col-span-6 flex items-center gap-2 ${domainError ? "input-error" : ""}`}
> >
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
<input <input
@ -48,6 +51,26 @@ export function DomainSearchCard({
}} }}
</controlsForm.Field> </controlsForm.Field>
<controlsForm.Field name="locationCode">
{(field) => (
<select
className="select select-bordered lg:col-span-2"
value={field.state.value}
onChange={(event) => {
const next = Number(event.target.value);
field.handleChange(next);
onLocationChange(next);
}}
>
{LOCATION_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
)}
</controlsForm.Field>
<controlsForm.Field name="sort"> <controlsForm.Field name="sort">
{(field) => ( {(field) => (
<select <select

View File

@ -26,11 +26,15 @@ export function saveSelectedKeywords({
filteredKeywords, filteredKeywords,
save, save,
projectId, projectId,
locationCode,
languageCode,
}: { }: {
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
filteredKeywords: DomainOverviewData["keywords"]; filteredKeywords: DomainOverviewData["keywords"];
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void; save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
projectId: string; projectId: string;
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");
@ -44,8 +48,8 @@ export function saveSelectedKeywords({
{ {
projectId, projectId,
keywords: [...selectedKeywords], keywords: [...selectedKeywords],
locationCode: 2840, locationCode,
languageCode: "en", languageCode,
metrics: selectedRows.map((row) => ({ metrics: selectedRows.map((row) => ({
keyword: row.keyword, keyword: row.keyword,
searchVolume: row.searchVolume, searchVolume: row.searchVolume,

View File

@ -25,6 +25,11 @@ import type {
SortOrder, SortOrder,
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory"; import type { DomainSearchHistoryItem } from "@/client/hooks/useDomainSearchHistory";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
export type SearchState = { export type SearchState = {
domain: string; domain: string;
@ -33,6 +38,7 @@ export type SearchState = {
order?: SortOrder; order?: SortOrder;
tab: DomainActiveTab; tab: DomainActiveTab;
search: string; search: string;
locationCode: number;
}; };
type DomainNavigate = (args: { type DomainNavigate = (args: {
@ -46,16 +52,18 @@ type DomainControlsFormAccess = {
domain: string; domain: string;
subdomains: boolean; subdomains: boolean;
sort: DomainSortMode; sort: DomainSortMode;
locationCode: number;
}; };
}; };
reset: (values: { reset: (values: {
domain: string; domain: string;
subdomains: boolean; subdomains: boolean;
sort: DomainSortMode; sort: DomainSortMode;
locationCode: number;
}) => void; }) => void;
setFieldValue: ( setFieldValue: (
field: "domain" | "subdomains" | "sort", field: "domain" | "subdomains" | "sort" | "locationCode",
updater: string | boolean, updater: string | boolean | number,
opts?: UpdateMetaOptions, opts?: UpdateMetaOptions,
) => void; ) => void;
}; };
@ -177,6 +185,7 @@ export function useSyncRouteState({
domain: searchState.domain, domain: searchState.domain,
subdomains: searchState.subdomains, subdomains: searchState.subdomains,
sort: searchState.sort, sort: searchState.sort,
locationCode: searchState.locationCode,
}); });
setPendingSearch(searchState.search); setPendingSearch(searchState.search);
}, [controlsForm, searchState, setPendingSearch]); }, [controlsForm, searchState, setPendingSearch]);
@ -185,6 +194,7 @@ export function useSyncRouteState({
const raw = new URLSearchParams(window.location.search); const raw = new URLSearchParams(window.location.search);
const rawSort = toSortMode(raw.get("sort")); const rawSort = toSortMode(raw.get("sort"));
const rawOrder = toSortOrder(raw.get("order")); const rawOrder = toSortOrder(raw.get("order"));
const rawLoc = raw.get("loc");
const shouldNormalize = const shouldNormalize =
raw.get("domain") === "" || raw.get("domain") === "" ||
raw.get("search") === "" || raw.get("search") === "" ||
@ -192,7 +202,8 @@ export function useSyncRouteState({
raw.get("sort") === "rank" || raw.get("sort") === "rank" ||
(rawOrder != null && (rawOrder != null &&
rawOrder === getDefaultSortOrder(rawSort ?? "rank")) || rawOrder === getDefaultSortOrder(rawSort ?? "rank")) ||
raw.get("tab") === "keywords"; raw.get("tab") === "keywords" ||
rawLoc === String(DEFAULT_LOCATION_CODE);
if (!shouldNormalize) return; if (!shouldNormalize) return;
navigate({ navigate({
@ -211,6 +222,10 @@ export function useSyncRouteState({
? undefined ? undefined
: prev.order, : prev.order,
tab: prev.tab === "keywords" ? undefined : prev.tab, tab: prev.tab === "keywords" ? undefined : prev.tab,
loc:
prev.loc != null && Number(prev.loc) === DEFAULT_LOCATION_CODE
? undefined
: prev.loc,
}; };
}, },
replace: true, replace: true,
@ -249,11 +264,11 @@ export function useSearchRunner({
controlsForm: ControlsFormLike; controlsForm: ControlsFormLike;
setPendingSearch: (value: string) => void; setPendingSearch: (value: string) => void;
setSearchParams: ( setSearchParams: (
updates: Record<string, string | boolean | undefined>, updates: Record<string, string | number | boolean | undefined>,
) => void; ) => void;
domainMutation: ReturnType<typeof useDomainLookupMutation>; domainMutation: ReturnType<typeof useDomainLookupMutation>;
addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void; addSearch: (item: Omit<DomainSearchHistoryItem, "timestamp">) => void;
setOverview: (value: DomainOverviewData) => void; setOverview: (value: DomainOverviewData, locationCode: number) => void;
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>; setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
currentState: SearchState; currentState: SearchState;
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
@ -266,6 +281,12 @@ export function useSearchRunner({
const activeOrder = params?.order ?? currentSortOrder; const activeOrder = params?.order ?? currentSortOrder;
const activeTab = params?.tab ?? currentState.tab; const activeTab = params?.tab ?? currentState.tab;
const activeSearch = params?.search ?? currentState.search; const activeSearch = params?.search ?? currentState.search;
const rawLocationCode =
params?.locationCode ?? values.locationCode ?? currentState.locationCode;
const activeLocationCode = isSupportedLocationCode(rawLocationCode)
? rawLocationCode
: DEFAULT_LOCATION_CODE;
const activeLanguageCode = getLanguageCode(activeLocationCode);
const target = normalizeDomainTarget(rawTarget); const target = normalizeDomainTarget(rawTarget);
if (!target) { if (!target) {
@ -276,6 +297,7 @@ export function useSearchRunner({
controlsForm.setFieldValue("domain", target); controlsForm.setFieldValue("domain", target);
controlsForm.setFieldValue("subdomains", activeSubdomains); controlsForm.setFieldValue("subdomains", activeSubdomains);
controlsForm.setFieldValue("sort", activeSort); controlsForm.setFieldValue("sort", activeSort);
controlsForm.setFieldValue("locationCode", activeLocationCode);
setSearchParams({ setSearchParams({
domain: target, domain: target,
@ -284,23 +306,28 @@ export function useSearchRunner({
order: toSortOrderSearchParam(activeSort, activeOrder), order: toSortOrderSearchParam(activeSort, activeOrder),
tab: activeTab === "keywords" ? undefined : activeTab, tab: activeTab === "keywords" ? undefined : activeTab,
search: activeSearch.trim() || undefined, search: activeSearch.trim() || undefined,
loc:
activeLocationCode === DEFAULT_LOCATION_CODE
? undefined
: activeLocationCode,
}); });
try { try {
const response = await domainMutation.mutateAsync({ const response = await domainMutation.mutateAsync({
domain: target, domain: target,
includeSubdomains: activeSubdomains, includeSubdomains: activeSubdomains,
locationCode: 2840, locationCode: activeLocationCode,
languageCode: "en", languageCode: activeLanguageCode,
}); });
captureClientEvent("domain_overview:search_complete", { captureClientEvent("domain_overview:search_complete", {
sort_mode: activeSort, sort_mode: activeSort,
include_subdomains: activeSubdomains, include_subdomains: activeSubdomains,
result_count: response.keywords.length, result_count: response.keywords.length,
location_code: activeLocationCode,
}); });
setOverview(response); setOverview(response, activeLocationCode);
setSelectedKeywords(new Set()); setSelectedKeywords(new Set());
addSearch({ addSearch({
domain: target, domain: target,
@ -308,6 +335,7 @@ export function useSearchRunner({
sort: activeSort, sort: activeSort,
tab: activeTab, tab: activeTab,
search: activeSearch.trim() || undefined, search: activeSearch.trim() || undefined,
locationCode: activeLocationCode,
}); });
if (!response.hasData) { if (!response.hasData) {

View File

@ -50,6 +50,7 @@ export type DomainControlsValues = {
domain: string; domain: string;
subdomains: boolean; subdomains: boolean;
sort: "rank" | "traffic" | "volume" | "score" | "cpc"; sort: "rank" | "traffic" | "volume" | "score" | "cpc";
locationCode: number;
}; };
export type DomainSortMode = DomainControlsValues["sort"]; export type DomainSortMode = DomainControlsValues["sort"];
@ -74,4 +75,5 @@ export type DomainHistoryItem = {
sort: DomainSortMode; sort: DomainSortMode;
tab: DomainActiveTab; tab: DomainActiveTab;
search?: string; search?: string;
locationCode?: number;
}; };

View File

@ -32,6 +32,11 @@ import {
useSyncRouteState, useSyncRouteState,
type SearchState, type SearchState,
} from "@/client/features/domain/domainOverviewControllerInternals"; } from "@/client/features/domain/domainOverviewControllerInternals";
import {
DEFAULT_LOCATION_CODE,
getLanguageCode,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
type Params = { type Params = {
projectId: string; projectId: string;
@ -51,7 +56,7 @@ type DomainControlsFormApi = {
reset: (values: DomainControlsValues) => void; reset: (values: DomainControlsValues) => void;
setFieldValue: ( setFieldValue: (
field: keyof DomainControlsValues, field: keyof DomainControlsValues,
value: string | boolean, value: string | boolean | number,
) => void; ) => void;
}; };
@ -107,6 +112,9 @@ export function useDomainOverviewController({
}: Params) { }: Params) {
const [pendingSearch, setPendingSearch] = useState(searchState.search); const [pendingSearch, setPendingSearch] = useState(searchState.search);
const [overview, setOverview] = useState<DomainOverviewData | null>(null); const [overview, setOverview] = useState<DomainOverviewData | null>(null);
const [overviewLocationCode, setOverviewLocationCode] = useState<
number | null
>(null);
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>( const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(
new Set(), new Set(),
); );
@ -138,6 +146,7 @@ export function useDomainOverviewController({
domain: searchState.domain, domain: searchState.domain,
subdomains: searchState.subdomains, subdomains: searchState.subdomains,
sort: searchState.sort, sort: searchState.sort,
locationCode: searchState.locationCode,
}, },
validators: { validators: {
onChange: ({ formApi, value }) => onChange: ({ formApi, value }) =>
@ -156,6 +165,7 @@ export function useDomainOverviewController({
order: currentSortOrder, order: currentSortOrder,
tab: searchState.tab, tab: searchState.tab,
search: searchState.search, search: searchState.search,
locationCode: value.locationCode,
}); });
formApi.setErrorMap({ formApi.setErrorMap({
@ -188,7 +198,10 @@ export function useDomainOverviewController({
setSearchParams, setSearchParams,
domainMutation, domainMutation,
addSearch, addSearch,
setOverview: (value) => setOverview(value), setOverview: (value, locationCode) => {
setOverview(value);
setOverviewLocationCode(locationCode);
},
setSelectedKeywords, setSelectedKeywords,
currentState: searchState, currentState: searchState,
currentSortOrder, currentSortOrder,
@ -199,6 +212,7 @@ export function useDomainOverviewController({
currentSortOrder, currentSortOrder,
currentState: searchState, currentState: searchState,
dataState, dataState,
overviewLocationCode,
projectId, projectId,
runSearch, runSearch,
saveMutation, saveMutation,
@ -206,8 +220,13 @@ export function useDomainOverviewController({
setSearchParams, setSearchParams,
}); });
const canSaveKeywords =
overviewLocationCode !== null &&
overviewLocationCode === controlsForm.state.values.locationCode;
const resetView = useCallback(() => { const resetView = useCallback(() => {
setOverview(null); setOverview(null);
setOverviewLocationCode(null);
setPendingSearch(""); setPendingSearch("");
setSelectedKeywords(new Set()); setSelectedKeywords(new Set());
setShowFilters(false); setShowFilters(false);
@ -218,6 +237,7 @@ export function useDomainOverviewController({
controlsForm, controlsForm,
isLoading: domainMutation.isPending, isLoading: domainMutation.isPending,
overview, overview,
canSaveKeywords,
history, history,
historyLoaded, historyLoaded,
removeHistoryItem, removeHistoryItem,
@ -241,6 +261,7 @@ function useDomainControllerHandlers({
currentSortOrder, currentSortOrder,
currentState, currentState,
dataState, dataState,
overviewLocationCode,
projectId, projectId,
runSearch, runSearch,
saveMutation, saveMutation,
@ -251,6 +272,7 @@ function useDomainControllerHandlers({
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
currentState: SearchState; currentState: SearchState;
dataState: ReturnType<typeof useOverviewDataState>; dataState: ReturnType<typeof useOverviewDataState>;
overviewLocationCode: number | null;
projectId: string; projectId: string;
runSearch: ReturnType<typeof useSearchRunner>; runSearch: ReturnType<typeof useSearchRunner>;
saveMutation: ReturnType<typeof useSaveKeywordsMutation>; saveMutation: ReturnType<typeof useSaveKeywordsMutation>;
@ -270,6 +292,20 @@ function useDomainControllerHandlers({
[controlsForm, setSearchParams], [controlsForm, setSearchParams],
); );
const applyLocationChange = useCallback(
(nextLocationCode: number) => {
if (!isSupportedLocationCode(nextLocationCode)) return;
controlsForm.setFieldValue("locationCode", nextLocationCode);
setSearchParams({
loc:
nextLocationCode === DEFAULT_LOCATION_CODE
? undefined
: nextLocationCode,
});
},
[controlsForm, setSearchParams],
);
const handleSortColumnClick = useCallback( const handleSortColumnClick = useCallback(
(nextSort: DomainSortMode) => { (nextSort: DomainSortMode) => {
const nextOrder = const nextOrder =
@ -283,19 +319,28 @@ function useDomainControllerHandlers({
[applySort, currentSortOrder, currentState.sort], [applySort, currentSortOrder, currentState.sort],
); );
const handleSaveKeywords = () => const handleSaveKeywords = () => {
if (overviewLocationCode === null) return;
saveSelectedKeywords({ saveSelectedKeywords({
selectedKeywords, selectedKeywords,
filteredKeywords: dataState.filteredKeywords, filteredKeywords: dataState.filteredKeywords,
save: saveMutation.mutate, save: saveMutation.mutate,
projectId, projectId,
locationCode: overviewLocationCode,
languageCode: getLanguageCode(overviewLocationCode),
}); });
};
const handleHistorySelect = (item: DomainSearchHistoryItem) => { const handleHistorySelect = (item: DomainSearchHistoryItem) => {
const historyLocation =
item.locationCode != null && isSupportedLocationCode(item.locationCode)
? item.locationCode
: DEFAULT_LOCATION_CODE;
controlsForm.reset({ controlsForm.reset({
domain: item.domain, domain: item.domain,
subdomains: item.subdomains, subdomains: item.subdomains,
sort: item.sort, sort: item.sort,
locationCode: historyLocation,
}); });
void runSearch({ void runSearch({
domain: item.domain, domain: item.domain,
@ -304,6 +349,7 @@ function useDomainControllerHandlers({
order: getDefaultSortOrder(item.sort), order: getDefaultSortOrder(item.sort),
tab: item.tab, tab: item.tab,
search: item.search ?? "", search: item.search ?? "",
locationCode: historyLocation,
}); });
}; };
@ -314,6 +360,7 @@ function useDomainControllerHandlers({
return { return {
applySort, applySort,
applyLocationChange,
handleSortColumnClick, handleSortColumnClick,
handleSaveKeywords, handleSaveKeywords,
runSearch, runSearch,

View File

@ -3,6 +3,7 @@ import { useState } from "react";
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, getLanguageCode } from "@/client/features/keywords/utils";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { researchKeywords } from "@/serverFunctions/keywords"; import { researchKeywords } from "@/serverFunctions/keywords";
import type { import type {
KeywordMode, KeywordMode,
@ -25,7 +26,9 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
useState<KeywordSource>("related"); useState<KeywordSource>("related");
const [lastUsedFallback, setLastUsedFallback] = useState(false); const [lastUsedFallback, setLastUsedFallback] = useState(false);
const [lastSearchKeyword, setLastSearchKeyword] = useState(""); const [lastSearchKeyword, setLastSearchKeyword] = useState("");
const [lastSearchLocationCode, setLastSearchLocationCode] = useState(2840); const [lastSearchLocationCode, setLastSearchLocationCode] = useState(
DEFAULT_LOCATION_CODE,
);
const [researchError, setResearchError] = useState<string | null>(null); const [researchError, setResearchError] = useState<string | null>(null);
const [searchedKeyword, setSearchedKeyword] = useState(""); const [searchedKeyword, setSearchedKeyword] = useState("");
@ -56,7 +59,7 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
setLastResultSource("related"); setLastResultSource("related");
setLastUsedFallback(false); setLastUsedFallback(false);
setLastSearchKeyword(""); setLastSearchKeyword("");
setLastSearchLocationCode(2840); setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
setResearchError(null); setResearchError(null);
setSearchedKeyword(""); setSearchedKeyword("");
}; };

View File

@ -1,53 +1,138 @@
/**
* DataForSEO-supported countries.
*
* Source: https://cdn.dataforseo.com/v3/locations/locations_and_languages_dataforseo_labs_2026_04_06.csv
*
* For countries with multiple Google-supported languages, we pick the
* language with the largest keyword corpus (the primary search market)
* as the default. DataForSEO Labs APIs accept a single location_code +
* language_code pair per request, so we expose one entry per country.
*
* Entries are sorted alphabetically by country name; pick US as the
* product-wide default via DEFAULT_LOCATION_CODE below.
*/
export const DEFAULT_LOCATION_CODE = 2840; export const DEFAULT_LOCATION_CODE = 2840;
export const LOCATION_OPTIONS = [ export const LOCATION_OPTIONS = [
{ code: 2840, label: "United States", shortLabel: "US", languageCode: "en" }, { code: 2008, label: "Albania", shortLabel: "AL", languageCode: "sq" },
{ code: 2826, label: "United Kingdom", shortLabel: "UK", languageCode: "en" }, { code: 2012, label: "Algeria", shortLabel: "DZ", languageCode: "fr" },
{ code: 2124, label: "Canada", shortLabel: "CA", languageCode: "en" }, { code: 2024, label: "Angola", shortLabel: "AO", languageCode: "pt" },
{ code: 2036, label: "Australia", shortLabel: "AU", languageCode: "en" },
{ code: 2372, label: "Ireland", shortLabel: "IE", languageCode: "en" },
{ code: 2554, label: "New Zealand", shortLabel: "NZ", languageCode: "en" },
{ code: 2356, label: "India", shortLabel: "IN", languageCode: "en" },
{ code: 2702, label: "Singapore", shortLabel: "SG", languageCode: "en" },
{ code: 2710, label: "South Africa", shortLabel: "ZA", languageCode: "en" },
{ code: 2608, label: "Philippines", shortLabel: "PH", languageCode: "en" },
{ code: 2276, label: "Germany", shortLabel: "DE", languageCode: "de" },
{ code: 2250, label: "France", shortLabel: "FR", languageCode: "fr" },
{ code: 2528, label: "Netherlands", shortLabel: "NL", languageCode: "nl" },
{ code: 2724, label: "Spain", shortLabel: "ES", languageCode: "es" },
{ code: 2380, label: "Italy", shortLabel: "IT", languageCode: "it" },
{ code: 2620, label: "Portugal", shortLabel: "PT", languageCode: "pt" },
{ code: 2040, label: "Austria", shortLabel: "AT", languageCode: "de" },
{ code: 2756, label: "Switzerland", shortLabel: "CH", languageCode: "de" },
{ code: 2752, label: "Sweden", shortLabel: "SE", languageCode: "sv" },
{ code: 2578, label: "Norway", shortLabel: "NO", languageCode: "nb" },
{ code: 2208, label: "Denmark", shortLabel: "DK", languageCode: "da" },
{ code: 2616, label: "Poland", shortLabel: "PL", languageCode: "pl" },
{ code: 2203, label: "Czechia", shortLabel: "CZ", languageCode: "cs" },
{ code: 2642, label: "Romania", shortLabel: "RO", languageCode: "ro" },
{ code: 2792, label: "Turkey", shortLabel: "TR", languageCode: "tr" },
{ code: 2300, label: "Greece", shortLabel: "GR", languageCode: "el" },
{ code: 2348, label: "Hungary", shortLabel: "HU", languageCode: "hu" },
{ code: 2076, label: "Brazil", shortLabel: "BR", languageCode: "pt" },
{ code: 2484, label: "Mexico", shortLabel: "MX", languageCode: "es" },
{ code: 2032, label: "Argentina", shortLabel: "AR", languageCode: "es" }, { code: 2032, label: "Argentina", shortLabel: "AR", languageCode: "es" },
{ code: 2170, label: "Colombia", shortLabel: "CO", languageCode: "es" }, { code: 2051, label: "Armenia", shortLabel: "AM", languageCode: "hy" },
{ code: 2036, label: "Australia", shortLabel: "AU", languageCode: "en" },
{ code: 2040, label: "Austria", shortLabel: "AT", languageCode: "de" },
{ code: 2031, label: "Azerbaijan", shortLabel: "AZ", languageCode: "az" },
{ code: 2048, label: "Bahrain", shortLabel: "BH", languageCode: "ar" },
{ code: 2050, label: "Bangladesh", shortLabel: "BD", languageCode: "bn" },
{ code: 2056, label: "Belgium", shortLabel: "BE", languageCode: "nl" },
{ code: 2068, label: "Bolivia", shortLabel: "BO", languageCode: "es" },
{
code: 2070,
label: "Bosnia and Herzegovina",
shortLabel: "BA",
languageCode: "bs",
},
{ code: 2076, label: "Brazil", shortLabel: "BR", languageCode: "pt" },
{ code: 2100, label: "Bulgaria", shortLabel: "BG", languageCode: "bg" },
{ code: 2854, label: "Burkina Faso", shortLabel: "BF", languageCode: "fr" },
{ code: 2116, label: "Cambodia", shortLabel: "KH", languageCode: "en" },
{ code: 2120, label: "Cameroon", shortLabel: "CM", languageCode: "fr" },
{ code: 2124, label: "Canada", shortLabel: "CA", languageCode: "en" },
{ code: 2152, label: "Chile", shortLabel: "CL", languageCode: "es" }, { code: 2152, label: "Chile", shortLabel: "CL", languageCode: "es" },
{ code: 2604, label: "Peru", shortLabel: "PE", languageCode: "es" }, { code: 2170, label: "Colombia", shortLabel: "CO", languageCode: "es" },
{ code: 2392, label: "Japan", shortLabel: "JP", languageCode: "ja" }, { code: 2188, label: "Costa Rica", shortLabel: "CR", languageCode: "es" },
{ code: 2410, label: "South Korea", shortLabel: "KR", languageCode: "ko" }, { code: 2384, label: "Cote d'Ivoire", shortLabel: "CI", languageCode: "fr" },
{ code: 2191, label: "Croatia", shortLabel: "HR", languageCode: "hr" },
{ code: 2196, label: "Cyprus", shortLabel: "CY", languageCode: "el" },
{ code: 2203, label: "Czechia", shortLabel: "CZ", languageCode: "cs" },
{ code: 2208, label: "Denmark", shortLabel: "DK", languageCode: "da" },
{ code: 2218, label: "Ecuador", shortLabel: "EC", languageCode: "es" },
{ code: 2818, label: "Egypt", shortLabel: "EG", languageCode: "ar" },
{ code: 2222, label: "El Salvador", shortLabel: "SV", languageCode: "es" },
{ code: 2233, label: "Estonia", shortLabel: "EE", languageCode: "et" },
{ code: 2246, label: "Finland", shortLabel: "FI", languageCode: "fi" },
{ code: 2250, label: "France", shortLabel: "FR", languageCode: "fr" },
{ code: 2276, label: "Germany", shortLabel: "DE", languageCode: "de" },
{ code: 2288, label: "Ghana", shortLabel: "GH", languageCode: "en" },
{ code: 2300, label: "Greece", shortLabel: "GR", languageCode: "el" },
{ code: 2320, label: "Guatemala", shortLabel: "GT", languageCode: "es" },
{ code: 2344, label: "Hong Kong", shortLabel: "HK", languageCode: "zh-TW" },
{ code: 2348, label: "Hungary", shortLabel: "HU", languageCode: "hu" },
{ code: 2356, label: "India", shortLabel: "IN", languageCode: "en" },
{ code: 2360, label: "Indonesia", shortLabel: "ID", languageCode: "id" }, { code: 2360, label: "Indonesia", shortLabel: "ID", languageCode: "id" },
{ code: 2458, label: "Malaysia", shortLabel: "MY", languageCode: "ms" }, { code: 2372, label: "Ireland", shortLabel: "IE", languageCode: "en" },
{ code: 2376, label: "Israel", shortLabel: "IL", languageCode: "he" },
{ code: 2380, label: "Italy", shortLabel: "IT", languageCode: "it" },
{ code: 2392, label: "Japan", shortLabel: "JP", languageCode: "ja" },
{ code: 2400, label: "Jordan", shortLabel: "JO", languageCode: "ar" },
{ code: 2398, label: "Kazakhstan", shortLabel: "KZ", languageCode: "ru" },
{ code: 2404, label: "Kenya", shortLabel: "KE", languageCode: "en" },
{ code: 2428, label: "Latvia", shortLabel: "LV", languageCode: "lv" },
{ code: 2440, label: "Lithuania", shortLabel: "LT", languageCode: "lt" },
{ code: 2458, label: "Malaysia", shortLabel: "MY", languageCode: "en" },
{ code: 2470, label: "Malta", shortLabel: "MT", languageCode: "en" },
{ code: 2484, label: "Mexico", shortLabel: "MX", languageCode: "es" },
{ code: 2498, label: "Moldova", shortLabel: "MD", languageCode: "ro" },
{ code: 2492, label: "Monaco", shortLabel: "MC", languageCode: "fr" },
{ code: 2504, label: "Morocco", shortLabel: "MA", languageCode: "ar" },
{
code: 2104,
label: "Myanmar (Burma)",
shortLabel: "MM",
languageCode: "en",
},
{ code: 2528, label: "Netherlands", shortLabel: "NL", languageCode: "nl" },
{ code: 2554, label: "New Zealand", shortLabel: "NZ", languageCode: "en" },
{ code: 2558, label: "Nicaragua", shortLabel: "NI", languageCode: "es" },
{ code: 2566, label: "Nigeria", shortLabel: "NG", languageCode: "en" },
{
code: 2807,
label: "North Macedonia",
shortLabel: "MK",
languageCode: "mk",
},
{ code: 2578, label: "Norway", shortLabel: "NO", languageCode: "nb" },
{ code: 2586, label: "Pakistan", shortLabel: "PK", languageCode: "en" },
{ code: 2591, label: "Panama", shortLabel: "PA", languageCode: "es" },
{ code: 2600, label: "Paraguay", shortLabel: "PY", languageCode: "es" },
{ code: 2604, label: "Peru", shortLabel: "PE", languageCode: "es" },
{ code: 2608, label: "Philippines", shortLabel: "PH", languageCode: "en" },
{ code: 2616, label: "Poland", shortLabel: "PL", languageCode: "pl" },
{ code: 2620, label: "Portugal", shortLabel: "PT", languageCode: "pt" },
{ code: 2642, label: "Romania", shortLabel: "RO", languageCode: "ro" },
{ code: 2682, label: "Saudi Arabia", shortLabel: "SA", languageCode: "ar" },
{ code: 2686, label: "Senegal", shortLabel: "SN", languageCode: "fr" },
{ code: 2688, label: "Serbia", shortLabel: "RS", languageCode: "sr" },
{ code: 2702, label: "Singapore", shortLabel: "SG", languageCode: "en" },
{ code: 2703, label: "Slovakia", shortLabel: "SK", languageCode: "sk" },
{ code: 2705, label: "Slovenia", shortLabel: "SI", languageCode: "sl" },
{ code: 2710, label: "South Africa", shortLabel: "ZA", languageCode: "en" },
{ code: 2410, label: "South Korea", shortLabel: "KR", languageCode: "ko" },
{ code: 2724, label: "Spain", shortLabel: "ES", languageCode: "es" },
{ code: 2144, label: "Sri Lanka", shortLabel: "LK", languageCode: "en" },
{ code: 2752, label: "Sweden", shortLabel: "SE", languageCode: "sv" },
{ code: 2756, label: "Switzerland", shortLabel: "CH", languageCode: "de" },
{ code: 2158, label: "Taiwan", shortLabel: "TW", languageCode: "zh-TW" },
{ code: 2764, label: "Thailand", shortLabel: "TH", languageCode: "th" }, { code: 2764, label: "Thailand", shortLabel: "TH", languageCode: "th" },
{ code: 2704, label: "Vietnam", shortLabel: "VN", languageCode: "vi" }, { code: 2788, label: "Tunisia", shortLabel: "TN", languageCode: "ar" },
{ code: 2792, label: "Turkiye", shortLabel: "TR", languageCode: "tr" },
{ code: 2804, label: "Ukraine", shortLabel: "UA", languageCode: "uk" },
{ {
code: 2784, code: 2784,
label: "United Arab Emirates", label: "United Arab Emirates",
shortLabel: "AE", shortLabel: "AE",
languageCode: "en", languageCode: "en",
}, },
{ code: 2682, label: "Saudi Arabia", shortLabel: "SA", languageCode: "ar" }, {
{ code: 2050, label: "Bangladesh", shortLabel: "BD", languageCode: "bn" }, code: 2826,
label: "United Kingdom",
shortLabel: "UK",
languageCode: "en",
},
{ code: 2840, label: "United States", shortLabel: "US", languageCode: "en" },
{ code: 2858, label: "Uruguay", shortLabel: "UY", languageCode: "es" },
{ code: 2862, label: "Venezuela", shortLabel: "VE", languageCode: "es" },
{ code: 2704, label: "Vietnam", shortLabel: "VN", languageCode: "vi" },
] as const; ] as const;
const LOCATION_CODES = new Set<number>( const LOCATION_CODES = new Set<number>(

View File

@ -11,6 +11,7 @@ export interface DomainSearchHistoryItem {
sort: DomainSortMode; sort: DomainSortMode;
tab: DomainTab; tab: DomainTab;
search?: string; search?: string;
locationCode?: number;
timestamp: number; timestamp: number;
} }
@ -24,6 +25,7 @@ const domainSearchHistoryItemSchema = z.object({
sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]), sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
tab: z.enum(["keywords", "pages"]), tab: z.enum(["keywords", "pages"]),
search: z.string().optional(), search: z.string().optional(),
locationCode: z.number().int().positive().optional(),
timestamp: z.number(), timestamp: z.number(),
}); });
@ -43,6 +45,7 @@ function isSameSearch(
a.subdomains === b.subdomains && a.subdomains === b.subdomains &&
a.sort === b.sort && a.sort === b.sort &&
a.tab === b.tab && a.tab === b.tab &&
a.locationCode === b.locationCode &&
normalizeSearchText(a.search) === normalizeSearchText(b.search) normalizeSearchText(a.search) === normalizeSearchText(b.search)
); );
} }

View File

@ -5,6 +5,10 @@ import {
toSortMode, toSortMode,
toSortOrder, toSortOrder,
} from "@/client/features/domain/utils"; } from "@/client/features/domain/utils";
import {
DEFAULT_LOCATION_CODE,
isSupportedLocationCode,
} from "@/client/features/keywords/locations";
import { domainSearchSchema } from "@/types/schemas/domain"; import { domainSearchSchema } from "@/types/schemas/domain";
export const Route = createFileRoute("/_project/p/$projectId/domain")({ export const Route = createFileRoute("/_project/p/$projectId/domain")({
@ -22,6 +26,7 @@ function DomainOverviewRoute() {
order, order,
tab = "keywords", tab = "keywords",
search = "", search = "",
loc,
} = Route.useSearch(); } = Route.useSearch();
const normalizedSort = toSortMode(sort) ?? "rank"; const normalizedSort = toSortMode(sort) ?? "rank";
@ -29,6 +34,8 @@ function DomainOverviewRoute() {
normalizedSort, normalizedSort,
toSortOrder(order ?? null), toSortOrder(order ?? null),
); );
const normalizedLocationCode =
loc != null && isSupportedLocationCode(loc) ? loc : DEFAULT_LOCATION_CODE;
return ( return (
<DomainOverviewPage <DomainOverviewPage
@ -43,6 +50,7 @@ function DomainOverviewRoute() {
order: undefined, order: undefined,
tab: undefined, tab: undefined,
search: undefined, search: undefined,
loc: undefined,
}), }),
replace: true, replace: true,
}); });
@ -55,6 +63,7 @@ function DomainOverviewRoute() {
order: normalizedOrder, order: normalizedOrder,
tab, tab,
search, search,
locationCode: normalizedLocationCode,
}} }}
/> />
); );

View File

@ -65,4 +65,5 @@ export const domainSearchSchema = z.object({
order: z.enum(domainSortOrders).optional(), order: z.enum(domainSortOrders).optional(),
tab: z.enum(domainTabs).optional(), tab: z.enum(domainTabs).optional(),
search: z.string().optional(), search: z.string().optional(),
loc: z.coerce.number().int().positive().optional(),
}); });