feat(rank-tracking): allow explicit language selection (#46)

* feat(rank-tracking): allow explicit language selection

* fix(rank-tracking): remove duplicate country picker and fix LocationSelect handler

The modal rendered two country pickers: a stale native <select> with a
broken empty onChange and the new LocationSelect combobox. The LocationSelect
handler also treated its numeric argument as a DOM event (Number(e.target.value)
-> NaN). Remove the dead <select> and pass the location code through directly.

* feat(rank-tracking): offer the full DataForSEO SERP language list

The language picker only listed the ~43 languages that happened to be a
country's default, so users could not track e.g. Hindi, Chinese (Simplified),
Tamil, or Urdu. Expand LANGUAGE_OPTIONS to the full set of languages the
DataForSEO SERP (Google) API accepts (sourced live from
/v3/serp/google/languages), dropping the deprecated 'iw' Hebrew duplicate and
the redundant 'no' (Norway uses 'nb', which both SERP and Labs accept). Mismatched
location+language pairs are accepted by SERP and invalid codes fail at zero cost,
so the wider list adds no charged-but-failed risk.

Also drop the now-unused LOCATION_OPTIONS re-export from the client shim.

* docs(rank-tracking): cite DataForSEO source for the language list

Note where LANGUAGE_OPTIONS comes from (/v3/serp/google/languages) and how it
relates to the country list (the Labs locations_and_languages endpoint), plus
the intentional deviations from the raw endpoint.

* chore(knip): treat scripts/** as entry points

The standalone CLI/dev scripts under scripts/ are invoked via package.json
scripts (tsx/node), not imported, so knip flagged all six as unused files.
Add scripts/** to knip entry so ci:check passes. (Pre-existing on main;
bundled here to keep the branch's ci:check green.)

* feat(rank-tracking): filter language picker to the country's supported languages

Showing all 128 languages for every country was noise. Restrict the picker to
the languages DataForSEO actually supports for the selected country via a new
getLanguageOptions() helper, backed by a per-country map from the Labs
locations_and_languages endpoint. Most countries expose only their default
language; the ~20 genuinely multilingual ones (US en/es, Canada en/fr,
Switzerland de/fr/it, India en/hi, etc.) list their real set. googleAdsOnly
countries have no per-country language data, so they show their default only.
The Language select is disabled when a country offers a single language.

LANGUAGE_OPTIONS becomes the internal master list (no longer exported).

---------

Co-authored-by: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Co-authored-by: Ben Senescu <bensenescu@gmail.com>
This commit is contained in:
Amol Budhwant 2026-06-29 03:34:49 +05:30 committed by GitHub
parent da06190e5f
commit a19baed678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 256 additions and 35 deletions

View File

@ -13,15 +13,11 @@
"src/db/index.ts", "src/db/index.ts",
"src/db/app.schema.ts", "src/db/app.schema.ts",
"src/db/better-auth-schema.ts", "src/db/better-auth-schema.ts",
// Standalone CLI/dev scripts, invoked via package.json scripts
"scripts/**",
], ],
"project": [ "project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
"**/*.{js,mjs,ts,tsx}", "ignore": ["drizzle-prod.config.ts"],
"!src/routeTree.gen.ts",
"!web/**"
],
"ignore": [
"drizzle-prod.config.ts"
],
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers // Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
"drizzle": false, "drizzle": false,
"ignoreDependencies": [ "ignoreDependencies": [

View File

@ -5,6 +5,7 @@ export {
LABS_LOCATION_OPTIONS, LABS_LOCATION_OPTIONS,
LOCATIONS, LOCATIONS,
getLanguageCode, getLanguageCode,
getLanguageOptions,
isLabsLocationCode, isLabsLocationCode,
isSupportedLocationCode, isSupportedLocationCode,
} from "@/shared/keyword-locations"; } from "@/shared/keyword-locations";

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
@ -19,6 +19,7 @@ import {
import { import {
DEFAULT_LOCATION_CODE, DEFAULT_LOCATION_CODE,
getLanguageCode, getLanguageCode,
getLanguageOptions,
} from "@/client/features/keywords/locations"; } from "@/client/features/keywords/locations";
import { LocationSelect } from "@/client/components/LocationSelect"; import { LocationSelect } from "@/client/components/LocationSelect";
import { KeywordSuggestionStep } from "./KeywordSuggestionStep"; import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
@ -47,6 +48,14 @@ export function RankTrackingConfigModal({
const [locationCode, setLocationCode] = useState( const [locationCode, setLocationCode] = useState(
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE, existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
); );
const [languageCode, setLanguageCode] = useState(
existingConfig?.languageCode ??
getLanguageCode(existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE),
);
const languageOptions = useMemo(
() => getLanguageOptions(locationCode),
[locationCode],
);
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40); const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40);
const [schedule, setSchedule] = useState< const [schedule, setSchedule] = useState<
RankTrackingConfig["scheduleInterval"] RankTrackingConfig["scheduleInterval"]
@ -62,7 +71,7 @@ export function RankTrackingConfigModal({
devices, devices,
serpDepth, serpDepth,
locationCode, locationCode,
languageCode: getLanguageCode(locationCode), languageCode,
scheduleInterval: schedule, scheduleInterval: schedule,
}, },
}), }),
@ -88,7 +97,7 @@ export function RankTrackingConfigModal({
devices, devices,
serpDepth, serpDepth,
locationCode, locationCode,
languageCode: getLanguageCode(locationCode), languageCode,
scheduleInterval: schedule, scheduleInterval: schedule,
}, },
}), }),
@ -146,7 +155,7 @@ export function RankTrackingConfigModal({
projectId={projectId} projectId={projectId}
domain={domain} domain={domain}
locationCode={locationCode} locationCode={locationCode}
languageCode={getLanguageCode(locationCode)} languageCode={languageCode}
onDone={(id) => onSaved(id)} onDone={(id) => onSaved(id)}
onClose={closeKeywordStep} onClose={closeKeywordStep}
/> />
@ -188,7 +197,31 @@ export function RankTrackingConfigModal({
<label className="label"> <label className="label">
<span className="label-text font-medium">Country</span> <span className="label-text font-medium">Country</span>
</label> </label>
<LocationSelect value={locationCode} onChange={setLocationCode} /> <LocationSelect
value={locationCode}
onChange={(newLocationCode) => {
setLocationCode(newLocationCode);
setLanguageCode(getLanguageCode(newLocationCode));
}}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Language</span>
</label>
<select
className="select select-bordered w-full"
value={languageCode}
onChange={(e) => setLanguageCode(e.target.value)}
disabled={languageOptions.length <= 1}
>
{languageOptions.map((language) => (
<option key={language.code} value={language.code}>
{language.label}
</option>
))}
</select>
</div> </div>
<div className="form-control"> <div className="form-control">

View File

@ -490,6 +490,153 @@ export const LOCATION_OPTIONS: readonly LocationOption[] = [
}, },
] as const; ] as const;
/**
* Languages selectable for rank tracking, which runs against the DataForSEO
* SERP (Google) API. This is the full set of language codes that API accepts;
* source/refresh it from the live endpoint (auth required):
* GET https://api.dataforseo.com/v3/serp/google/languages
* (Country list above comes from the sibling Labs endpoint cited at the top of
* this file: /v3/dataforseo_labs/locations_and_languages.)
*
* `code` is the DataForSEO `language_code` (authoritative); `label` is its
* `language_name`, lightly cleaned for display. Deviations from the raw
* endpoint: the deprecated `iw` Hebrew alias and the redundant `no` are
* dropped (Norway uses `nb`, which both SERP and Labs accept). Every country
* default in LOCATION_OPTIONS must appear here so the picker can show it.
*
* This is the master list; the picker shows a per-country subset via
* getLanguageOptions() below.
*/
const LANGUAGE_OPTIONS = [
{ code: "af", label: "Afrikaans" },
{ code: "ak", label: "Akan" },
{ code: "sq", label: "Albanian" },
{ code: "am", label: "Amharic" },
{ code: "ar", label: "Arabic" },
{ code: "hy", label: "Armenian" },
{ code: "az", label: "Azerbaijani" },
{ code: "ban", label: "Balinese" },
{ code: "eu", label: "Basque" },
{ code: "be", label: "Belarusian" },
{ code: "bn", label: "Bengali" },
{ code: "bs", label: "Bosnian" },
{ code: "bg", label: "Bulgarian" },
{ code: "my", label: "Burmese" },
{ code: "ca", label: "Catalan" },
{ code: "ceb", label: "Cebuano" },
{ code: "ny", label: "Chichewa" },
{ code: "zh-CN", label: "Chinese (Simplified)" },
{ code: "zh-TW", label: "Chinese (Traditional)" },
{ code: "hr", label: "Croatian" },
{ code: "cs", label: "Czech" },
{ code: "da", label: "Danish" },
{ code: "nl", label: "Dutch" },
{ code: "en", label: "English" },
{ code: "et", label: "Estonian" },
{ code: "ee", label: "Ewe" },
{ code: "fo", label: "Faroese" },
{ code: "fa", label: "Farsi" },
{ code: "fil", label: "Filipino" },
{ code: "fi", label: "Finnish" },
{ code: "fr", label: "French" },
{ code: "fy", label: "Frisian" },
{ code: "gaa", label: "Ga" },
{ code: "gl", label: "Galician" },
{ code: "lg", label: "Ganda" },
{ code: "ka", label: "Georgian" },
{ code: "de", label: "German" },
{ code: "el", label: "Greek" },
{ code: "gu", label: "Gujarati" },
{ code: "ht", label: "Haitian" },
{ code: "ha", label: "Hausa" },
{ code: "he", label: "Hebrew" },
{ code: "hi", label: "Hindi" },
{ code: "hu", label: "Hungarian" },
{ code: "is", label: "Icelandic" },
{ code: "bem", label: "IciBemba" },
{ code: "ig", label: "Igbo" },
{ code: "id", label: "Indonesian" },
{ code: "ga", label: "Irish" },
{ code: "it", label: "Italian" },
{ code: "ja", label: "Japanese" },
{ code: "kn", label: "Kannada" },
{ code: "kk", label: "Kazakh" },
{ code: "km", label: "Khmer" },
{ code: "rw", label: "Kinyarwanda" },
{ code: "rn", label: "Kirundi" },
{ code: "kg", label: "Kongo" },
{ code: "ko", label: "Korean" },
{ code: "mfe", label: "Kreol morisien" },
{ code: "crs", label: "Kreol Seselwa" },
{ code: "kri", label: "Krio" },
{ code: "ckb", label: "Kurdish" },
{ code: "ky", label: "Kyrgyz" },
{ code: "lo", label: "Lao" },
{ code: "lv", label: "Latvian" },
{ code: "ln", label: "Lingala" },
{ code: "lt", label: "Lithuanian" },
{ code: "ach", label: "Luo" },
{ code: "mk", label: "Macedonian" },
{ code: "mg", label: "Malagasy" },
{ code: "ms", label: "Malay" },
{ code: "ml", label: "Malayalam" },
{ code: "mt", label: "Maltese" },
{ code: "mi", label: "Maori" },
{ code: "mr", label: "Marathi" },
{ code: "mn", label: "Mongolian" },
{ code: "ne", label: "Nepali" },
{ code: "nso", label: "Northern Sotho" },
{ code: "nb", label: "Norwegian (Bokmål)" },
{ code: "nyn", label: "Nyankole" },
{ code: "om", label: "Oromo" },
{ code: "ps", label: "Pashto" },
{ code: "pcm", label: "Pidgin" },
{ code: "pl", label: "Polish" },
{ code: "pt", label: "Portuguese" },
{ code: "pt-BR", label: "Portuguese (Brazil)" },
{ code: "pt-PT", label: "Portuguese (Portugal)" },
{ code: "pa", label: "Punjabi" },
{ code: "qu", label: "Quechua" },
{ code: "ro", label: "Romanian" },
{ code: "rm", label: "Romansh" },
{ code: "ru", label: "Russian" },
{ code: "sr", label: "Serbian" },
{ code: "sr-Latn", label: "Serbian (Latin)" },
{ code: "sr-ME", label: "Serbian (Montenegro)" },
{ code: "st", label: "Sesotho" },
{ code: "sn", label: "Shona" },
{ code: "loz", label: "Silozi" },
{ code: "sd", label: "Sindhi" },
{ code: "si", label: "Sinhalese" },
{ code: "sk", label: "Slovak" },
{ code: "sl", label: "Slovenian" },
{ code: "so", label: "Somali" },
{ code: "es", label: "Spanish" },
{ code: "es-419", label: "Spanish (Latin America)" },
{ code: "sw", label: "Swahili" },
{ code: "sv", label: "Swedish" },
{ code: "tl", label: "Tagalog" },
{ code: "tg", label: "Tajik" },
{ code: "ta", label: "Tamil" },
{ code: "te", label: "Telugu" },
{ code: "th", label: "Thai" },
{ code: "ti", label: "Tigrinya" },
{ code: "to", label: "Tonga (Tonga Islands)" },
{ code: "lua", label: "Tshiluba" },
{ code: "tn", label: "Tswana" },
{ code: "tum", label: "Tumbuka" },
{ code: "tr", label: "Turkish" },
{ code: "tk", label: "Turkmen" },
{ code: "uk", label: "Ukrainian" },
{ code: "ur", label: "Urdu" },
{ code: "uz", label: "Uzbek" },
{ code: "vi", label: "Vietnamese" },
{ code: "cy", label: "Welsh" },
{ code: "wo", label: "Wolof" },
{ code: "xh", label: "Xhosa" },
{ code: "yo", label: "Yoruba" },
{ code: "zu", label: "Zulu" },
] as const;
/** Countries usable by DataForSEO Labs features (domain overview etc.). */ /** Countries usable by DataForSEO Labs features (domain overview etc.). */
export const LABS_LOCATION_OPTIONS = LOCATION_OPTIONS.filter( export const LABS_LOCATION_OPTIONS = LOCATION_OPTIONS.filter(
(option) => !option.googleAdsOnly, (option) => !option.googleAdsOnly,
@ -515,6 +662,50 @@ export function getLanguageCode(locationCode: number): string {
return LOCATION_LANGUAGE[locationCode] ?? "en"; return LOCATION_LANGUAGE[locationCode] ?? "en";
} }
/**
* Countries where DataForSEO offers more than one language, from the Labs
* locations_and_languages endpoint (each country's default is included).
* Every other country offers just its single default (see getLanguageOptions);
* googleAdsOnly countries have no per-country language data, so they fall back
* to the default too. Keep each list's codes present in LANGUAGE_OPTIONS.
*/
const MULTI_LANGUAGE_LOCATIONS: Record<number, readonly string[]> = {
2012: ["ar", "fr"], // Algeria
2056: ["de", "fr", "nl"], // Belgium
2124: ["en", "fr"], // Canada
2196: ["el", "en"], // Cyprus
2300: ["el", "en"], // Greece
2344: ["en", "zh-TW"], // Hong Kong
2356: ["en", "hi"], // India
2360: ["en", "id"], // Indonesia
2376: ["ar", "he"], // Israel
2458: ["en", "ms"], // Malaysia
2504: ["ar", "fr"], // Morocco
2586: ["en", "ur"], // Pakistan
2608: ["en", "tl"], // Philippines
2702: ["en", "zh-CN"], // Singapore
2756: ["de", "fr", "it"], // Switzerland
2784: ["ar", "en"], // United Arab Emirates
2804: ["ru", "uk"], // Ukraine
2818: ["ar", "en"], // Egypt
2840: ["en", "es"], // United States
2704: ["en", "vi"], // Vietnam
};
/**
* Languages to offer for a location's rank-tracking config. Restricts the
* global LANGUAGE_OPTIONS list to the languages DataForSEO supports for that
* country, so the picker isn't a wall of irrelevant options.
*/
export function getLanguageOptions(
locationCode: number,
): readonly (typeof LANGUAGE_OPTIONS)[number][] {
const codes = new Set(
MULTI_LANGUAGE_LOCATIONS[locationCode] ?? [getLanguageCode(locationCode)],
);
return LANGUAGE_OPTIONS.filter((language) => codes.has(language.code));
}
export function isSupportedLocationCode(locationCode: number): boolean { export function isSupportedLocationCode(locationCode: number): boolean {
return LOCATION_CODES.has(locationCode); return LOCATION_CODES.has(locationCode);
} }