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",
"private": true,
"sideEffects": false,
"version": "0.0.6",
"version": "0.0.7",
"type": "module",
"scripts": {
"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;
tab: DomainActiveTab;
search: string;
locationCode: number;
};
navigate: (args: {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
@ -69,6 +70,9 @@ export function DomainOverviewPage({
onSortChange={(sort) =>
state.applySort(sort, getDefaultSortOrder(sort))
}
onLocationChange={(locationCode) =>
state.applyLocationChange(locationCode)
}
/>
{state.isLoading ? (
@ -148,6 +152,7 @@ export function DomainOverviewPage({
}}
onSearchChange={state.setPendingSearch}
onSaveKeywords={state.handleSaveKeywords}
canSaveKeywords={state.canSaveKeywords}
onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection}
onToggleAllVisible={state.toggleAllVisibleKeywords}

View File

@ -46,6 +46,7 @@ type Props = {
onTabChange: (tab: DomainActiveTab) => void;
onSearchChange: (value: string) => void;
onSaveKeywords: () => void;
canSaveKeywords: boolean;
onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => void;
@ -69,6 +70,7 @@ export function DomainResultsCard({
onTabChange,
onSearchChange,
onSaveKeywords,
canSaveKeywords,
onSortClick,
onToggleKeyword,
onToggleAllVisible,
@ -124,7 +126,12 @@ export function DomainResultsCard({
<button
className="btn btn-sm"
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
</button>

View File

@ -4,12 +4,14 @@ import { getFieldError, getFormError } from "@/client/lib/forms";
import type { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
import { toSortMode } from "@/client/features/domain/utils";
import type { DomainSortMode } from "@/client/features/domain/types";
import { LOCATION_OPTIONS } from "@/client/features/keywords/locations";
type Props = {
controlsForm: ReturnType<typeof useDomainOverviewController>["controlsForm"];
isLoading: boolean;
onSubmit: (event: FormEvent) => void;
onSortChange: (sort: DomainSortMode) => void;
onLocationChange: (locationCode: number) => void;
};
export function DomainSearchCard({
@ -17,6 +19,7 @@ export function DomainSearchCard({
isLoading,
onSubmit,
onSortChange,
onLocationChange,
}: Props) {
return (
<div className="card bg-base-100 border border-base-300">
@ -31,7 +34,7 @@ export function DomainSearchCard({
return (
<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" />
<input
@ -48,6 +51,26 @@ export function DomainSearchCard({
}}
</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">
{(field) => (
<select

View File

@ -26,11 +26,15 @@ export function saveSelectedKeywords({
filteredKeywords,
save,
projectId,
locationCode,
languageCode,
}: {
selectedKeywords: Set<string>;
filteredKeywords: DomainOverviewData["keywords"];
save: (payload: Parameters<SaveMutation>[0], opts?: SaveOptions) => void;
projectId: string;
locationCode: number;
languageCode: string;
}) {
if (selectedKeywords.size === 0) {
toast.error("Select at least one keyword first");
@ -44,8 +48,8 @@ export function saveSelectedKeywords({
{
projectId,
keywords: [...selectedKeywords],
locationCode: 2840,
languageCode: "en",
locationCode,
languageCode,
metrics: selectedRows.map((row) => ({
keyword: row.keyword,
searchVolume: row.searchVolume,

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { LOCATIONS, getLanguageCode } from "@/client/features/keywords/utils";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { researchKeywords } from "@/serverFunctions/keywords";
import type {
KeywordMode,
@ -25,7 +26,9 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
useState<KeywordSource>("related");
const [lastUsedFallback, setLastUsedFallback] = useState(false);
const [lastSearchKeyword, setLastSearchKeyword] = useState("");
const [lastSearchLocationCode, setLastSearchLocationCode] = useState(2840);
const [lastSearchLocationCode, setLastSearchLocationCode] = useState(
DEFAULT_LOCATION_CODE,
);
const [researchError, setResearchError] = useState<string | null>(null);
const [searchedKeyword, setSearchedKeyword] = useState("");
@ -56,7 +59,7 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
setLastResultSource("related");
setLastUsedFallback(false);
setLastSearchKeyword("");
setLastSearchLocationCode(2840);
setLastSearchLocationCode(DEFAULT_LOCATION_CODE);
setResearchError(null);
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 LOCATION_OPTIONS = [
{ code: 2840, label: "United States", shortLabel: "US", languageCode: "en" },
{ code: 2826, label: "United Kingdom", shortLabel: "UK", languageCode: "en" },
{ code: 2124, label: "Canada", shortLabel: "CA", languageCode: "en" },
{ 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: 2008, label: "Albania", shortLabel: "AL", languageCode: "sq" },
{ code: 2012, label: "Algeria", shortLabel: "DZ", languageCode: "fr" },
{ code: 2024, label: "Angola", shortLabel: "AO", languageCode: "pt" },
{ 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: 2604, label: "Peru", shortLabel: "PE", languageCode: "es" },
{ code: 2392, label: "Japan", shortLabel: "JP", languageCode: "ja" },
{ code: 2410, label: "South Korea", shortLabel: "KR", languageCode: "ko" },
{ code: 2170, label: "Colombia", shortLabel: "CO", languageCode: "es" },
{ code: 2188, label: "Costa Rica", shortLabel: "CR", languageCode: "es" },
{ 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: 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: 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,
label: "United Arab Emirates",
shortLabel: "AE",
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;
const LOCATION_CODES = new Set<number>(

View File

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

View File

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

View File

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