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:
parent
da06190e5f
commit
a19baed678
12
knip.jsonc
12
knip.jsonc
@ -13,15 +13,11 @@
|
||||
"src/db/index.ts",
|
||||
"src/db/app.schema.ts",
|
||||
"src/db/better-auth-schema.ts",
|
||||
// Standalone CLI/dev scripts, invoked via package.json scripts
|
||||
"scripts/**",
|
||||
],
|
||||
"project": [
|
||||
"**/*.{js,mjs,ts,tsx}",
|
||||
"!src/routeTree.gen.ts",
|
||||
"!web/**"
|
||||
],
|
||||
"ignore": [
|
||||
"drizzle-prod.config.ts"
|
||||
],
|
||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||
"ignore": ["drizzle-prod.config.ts"],
|
||||
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
|
||||
"drizzle": false,
|
||||
"ignoreDependencies": [
|
||||
|
||||
@ -5,6 +5,7 @@ export {
|
||||
LABS_LOCATION_OPTIONS,
|
||||
LOCATIONS,
|
||||
getLanguageCode,
|
||||
getLanguageOptions,
|
||||
isLabsLocationCode,
|
||||
isSupportedLocationCode,
|
||||
} from "@/shared/keyword-locations";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
DEFAULT_LOCATION_CODE,
|
||||
getLanguageCode,
|
||||
getLanguageOptions,
|
||||
} from "@/client/features/keywords/locations";
|
||||
import { LocationSelect } from "@/client/components/LocationSelect";
|
||||
import { KeywordSuggestionStep } from "./KeywordSuggestionStep";
|
||||
@ -47,6 +48,14 @@ export function RankTrackingConfigModal({
|
||||
const [locationCode, setLocationCode] = useState(
|
||||
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 [schedule, setSchedule] = useState<
|
||||
RankTrackingConfig["scheduleInterval"]
|
||||
@ -62,7 +71,7 @@ export function RankTrackingConfigModal({
|
||||
devices,
|
||||
serpDepth,
|
||||
locationCode,
|
||||
languageCode: getLanguageCode(locationCode),
|
||||
languageCode,
|
||||
scheduleInterval: schedule,
|
||||
},
|
||||
}),
|
||||
@ -88,7 +97,7 @@ export function RankTrackingConfigModal({
|
||||
devices,
|
||||
serpDepth,
|
||||
locationCode,
|
||||
languageCode: getLanguageCode(locationCode),
|
||||
languageCode,
|
||||
scheduleInterval: schedule,
|
||||
},
|
||||
}),
|
||||
@ -146,7 +155,7 @@ export function RankTrackingConfigModal({
|
||||
projectId={projectId}
|
||||
domain={domain}
|
||||
locationCode={locationCode}
|
||||
languageCode={getLanguageCode(locationCode)}
|
||||
languageCode={languageCode}
|
||||
onDone={(id) => onSaved(id)}
|
||||
onClose={closeKeywordStep}
|
||||
/>
|
||||
@ -188,7 +197,31 @@ export function RankTrackingConfigModal({
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Country</span>
|
||||
</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 className="form-control">
|
||||
|
||||
@ -490,6 +490,153 @@ export const LOCATION_OPTIONS: readonly LocationOption[] = [
|
||||
},
|
||||
] 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.). */
|
||||
export const LABS_LOCATION_OPTIONS = LOCATION_OPTIONS.filter(
|
||||
(option) => !option.googleAdsOnly,
|
||||
@ -515,6 +662,50 @@ export function getLanguageCode(locationCode: number): string {
|
||||
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 {
|
||||
return LOCATION_CODES.has(locationCode);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user