diff --git a/src/components/Componentes/question-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index d821b2d..067844c 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -1,12 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Ic } from "@/icons"; import { getCountryList, resolveCountryName, isKnownCountry, + filterCountryOptions, } from "@/data/countries"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; @@ -433,9 +434,10 @@ export function QuestionBirthplace({ lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null; }, [rawValue, locale, selectedCountry, cityInput]); - const options = getCountryList(locale); - const filteredOptions = options.filter((option) => - option.toLowerCase().includes(searchQuery.toLowerCase()), + const options = useMemo(() => getCountryList(locale), [locale]); + const filteredOptions = useMemo( + () => filterCountryOptions(options, searchQuery, locale), + [options, searchQuery, locale], ); const handleSelectCountry = (country: string) => { @@ -486,26 +488,33 @@ export function QuestionBirthplace({ }; const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; - const selectCountryPlaceholder = isRtl ? "انتخاب کشور" : "Select country"; + const selectCountryPlaceholder = + t["Select country"] || (isRtl ? "انتخاب کشور" : "Select country"); const cityPlaceholder = question.extras?.placeHolder || (isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood"); const searchPlaceholder = - locale === "fa" + t["Search..."] || + (locale === "fa" ? "جستجو..." : locale === "ar" ? "بحث..." : locale === "tr" ? "Ara..." - : "Search..."; + : locale === "ru" + ? "Поиск..." + : "Search..."); const noResultsText = - locale === "fa" + t["No options found"] || + (locale === "fa" ? "موردی یافت نشد" : locale === "ar" ? "لم يتم العثور على نتائج" - : "No options found"; + : locale === "ru" + ? "Ничего не найдено" + : "No options found"); return (
{ fireEvent.change(input, { target: { value: "0917123456" } }); expect(input.value).toBe("91 712 3456"); }); + + it("searches country in country code bottom sheet by country name, calling code, Persian name, and aliases", async () => { + setStoredUserGeoRegion({ country: "United Kingdom", countryCode: "GB", phoneCode: "+44" }); + + render(); + + // Open sheet + const countryButton = screen.getByRole("button", { name: /🇬🇧/i }); + fireEvent.click(countryButton); + + // Find search input + const searchInput = screen.getByPlaceholderText("Search..."); + + // 1. Search by English name "iran" + fireEvent.change(searchInput, { target: { value: "iran" } }); + expect(screen.getByText("Iran")).toBeDefined(); + expect(screen.getByText("+98")).toBeDefined(); + + // 2. Search by Persian name "ایران" + fireEvent.change(searchInput, { target: { value: "ایران" } }); + expect(screen.getByText("Iran")).toBeDefined(); + expect(screen.getByText("+98")).toBeDefined(); + + // 3. Search by calling code number "98" + fireEvent.change(searchInput, { target: { value: "98" } }); + expect(screen.getByText("Iran")).toBeDefined(); + expect(screen.getByText("+98")).toBeDefined(); + + // 4. Search by alias "USA" + fireEvent.change(searchInput, { target: { value: "USA" } }); + expect(screen.getByText("United States")).toBeDefined(); + expect(screen.getByText("+1")).toBeDefined(); + + // 5. Search by ISO code "DE" + fireEvent.change(searchInput, { target: { value: "DE" } }); + expect(screen.getByText("Germany")).toBeDefined(); + expect(screen.getByText("+49")).toBeDefined(); + + // Select Germany + fireEvent.click(screen.getByText("Germany")); + + // Sheet closes and phone country code is updated to +49 + await waitFor(() => { + expect(screen.queryByPlaceholderText("Search...")).toBeNull(); + expect(screen.getByText("+49")).toBeDefined(); + expect(screen.getByText("🇩🇪")).toBeDefined(); + }); + }); }); diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index c1274ca..79d0779 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -37,6 +37,7 @@ import { subscribeToUserGeoRegion, resetUserGeoRegionForTesting, } from "@/lib/geo-region"; +import { normalizeSearchString, COUNTRY_ALIASES } from "@/data/countries"; const phoneUtil = PhoneNumberUtil.getInstance(); @@ -526,7 +527,19 @@ export function QuestionPhone({ const displayNames = new Intl.DisplayNames([locale || "en"], { type: "region", }); - const list: { name: string; code: string; flag: string }[] = []; + const enDisplayNames = new Intl.DisplayNames(["en"], { + type: "region", + }); + const faDisplayNames = new Intl.DisplayNames(["fa"], { + type: "region", + }); + const list: { + name: string; + code: string; + flag: string; + callingCode: string; + searchTerms: string[]; + }[] = []; const seen = new Set(); for (const region of regions) { @@ -552,10 +565,26 @@ export function QuestionPhone({ .map((char) => 127397 + char.charCodeAt(0)); const flag = String.fromCodePoint(...codePoints); + const enName = enDisplayNames.of(region) || ""; + const faName = faDisplayNames.of(region) || ""; + const aliases = COUNTRY_ALIASES[region] || []; + + const searchTerms = [ + normalizeSearchString(name), + normalizeSearchString(enName), + normalizeSearchString(faName), + normalizeSearchString(region), + String(callingCode), + `+${callingCode}`, + ...aliases.map(normalizeSearchString), + ].filter(Boolean); + list.push({ name, code: `+${callingCode}`, flag, + callingCode: String(callingCode), + searchTerms: Array.from(new Set(searchTerms)), }); } catch {} } @@ -606,12 +635,57 @@ export function QuestionPhone({ }, [codeValue, countryList]); const filteredCountries = useMemo(() => { - const q = searchQuery.toLowerCase().trim(); - if (!q) return countryList; - return countryList.filter( - (c) => - c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q), - ); + const rawQ = searchQuery.trim(); + if (!rawQ) return countryList; + + const normQ = normalizeSearchString(rawQ); + const digitsOnly = rawQ.replace(/[^\d]/g, ""); + + const matched = countryList.filter((c) => { + // 1. Dial code / number match + if (digitsOnly.length > 0) { + if (c.callingCode.includes(digitsOnly) || c.code.includes(rawQ)) { + return true; + } + } + + // 2. Text / name / alias match + if (normQ.length > 0) { + if (c.searchTerms.some((term) => term.includes(normQ))) { + return true; + } + } + + return false; + }); + + return matched.sort((a, b) => { + // Exact dial code match (e.g. searching "98" prioritizes Iran +98 over Uzbekistan +998) + const aExactCode = digitsOnly && a.callingCode === digitsOnly; + const bExactCode = digitsOnly && b.callingCode === digitsOnly; + if (aExactCode && !bExactCode) return -1; + if (!aExactCode && bExactCode) return 1; + + // Prefix dial code match (e.g. searching "9" prioritizes +98 over +359) + const aStartsCode = digitsOnly && a.callingCode.startsWith(digitsOnly); + const bStartsCode = digitsOnly && b.callingCode.startsWith(digitsOnly); + if (aStartsCode && !bStartsCode) return -1; + if (!aStartsCode && bStartsCode) return 1; + + // Exact term match (e.g. searching "iran" prioritizes Iran) + const aExactTerm = normQ && a.searchTerms.includes(normQ); + const bExactTerm = normQ && b.searchTerms.includes(normQ); + if (aExactTerm && !bExactTerm) return -1; + if (!aExactTerm && bExactTerm) return 1; + + // Prefix term match (e.g. searching "ira" prioritizes Iran, Iraq) + const aStartsTerm = normQ && a.searchTerms.some((t) => t.startsWith(normQ)); + const bStartsTerm = normQ && b.searchTerms.some((t) => t.startsWith(normQ)); + if (aStartsTerm && !bStartsTerm) return -1; + if (!aStartsTerm && bStartsTerm) return 1; + + return a.name.localeCompare(b.name); + }); }, [countryList, searchQuery]); useSheetScrollLock(isOpen, { onBack: closeSheet }); @@ -694,20 +768,26 @@ export function QuestionPhone({ const selectCountryTitle = t["Select country"] || question.title; const searchPlaceholder = - locale === "fa" + t["Search..."] || + (locale === "fa" ? "جستجو..." : locale === "ar" ? "بحث..." : locale === "tr" ? "Ara..." - : "Search..."; + : locale === "ru" + ? "Поиск..." + : "Search..."); const noResultsText = - locale === "fa" + t["No options found"] || + (locale === "fa" ? "موردی یافت نشد" : locale === "ar" ? "لم يتم العثور على نتائج" - : "No options found"; + : locale === "ru" + ? "Ничего не найдено" + : "No options found"); return (
{ expect(btn4).not.toBeDisabled(); expect(btn5).not.toBeDisabled(); }); + + it("renders localized country options dynamically in Russian", async () => { + const { useI18n } = await import("@/translations/provider"); + (useI18n as any).mockReturnValue({ + locale: "ru", + dictionary: { "Country of Birth": "Страна рождения", Confirm: "Подтверждать" }, + }); + + const countryQuestion = { + id: "personal_info.country_of_birth", + title: "Country of Birth", + type: "dropdown", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "Select country" }, + ui_config: { dataset: "countries" }, + options: [], + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button")); + + // Check that country names are in Russian + expect(screen.getByRole("button", { name: "Афганистан" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Албания" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Алжир" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Австралия" })).toBeDefined(); + }); + + it("renders localized language options dynamically in Russian", async () => { + const { useI18n } = await import("@/translations/provider"); + (useI18n as any).mockReturnValue({ + locale: "ru", + dictionary: { "Mother Tongue": "Родной язык", Confirm: "Подтверждать" }, + }); + + const languageQuestion = { + id: "personal_info.mother_tongue", + title: "Mother Tongue", + type: "dropdown", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "Select language" }, + ui_config: { dataset: "languages" }, + options: [], + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button")); + + // Check that language names are in Russian + expect(screen.getByRole("button", { name: "Африкаанс" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Арабский" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Русский" })).toBeDefined(); + }); }); + diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index f589ad0..79d398c 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -2,8 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; -import { LANGUAGE_EN_TO_FA, LANGUAGES_EN, LANGUAGES_FA } from "@/data/languages"; +import { + COUNTRIES_EN, + COUNTRIES_FA, + resolveCountryName, + normalizeSearchString, + getCountrySearchKeywords, +} from "@/data/countries"; +import { LANGUAGES_EN, LANGUAGES_FA, resolveLanguageName } from "@/data/languages"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { Button } from "./button"; @@ -161,10 +167,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const seenIds = new Set(); LANGUAGES_EN.forEach((enLang, idx) => { - const localizedLabel = - (locale === "fa" || locale === "fa-ir" - ? LANGUAGES_FA[idx] - : (t as any)[LANGUAGE_EN_TO_FA[enLang] || enLang]) || enLang; + const displayLabel = resolveLanguageName(enLang, locale); const cleanSlug = enLang .toLowerCase() .replace(/[^a-z0-9]+/g, "_") @@ -173,7 +176,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const existing = existingByLabel.get(enLang.toLowerCase()) || - existingByLabel.get(localizedLabel.toLowerCase()) || + existingByLabel.get(displayLabel.toLowerCase()) || existingByVal.get(cleanSlug) || existingById.get(generatedId.toLowerCase()); @@ -181,11 +184,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { if (seenIds.has(finalId)) return; seenIds.add(finalId); - const isPersian = locale === "fa" || locale === "fa-ir"; - const displayLabel = isPersian - ? (LANGUAGES_FA[idx] || localizedLabel) - : enLang; - mergedOptions.push({ id: finalId, value: existing?.value ?? cleanSlug, @@ -219,10 +217,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const seenIds = new Set(); COUNTRIES_EN.forEach((enCountry, idx) => { - const localizedLabel = - (locale === "fa" || locale === "fa-ir" - ? COUNTRIES_FA[idx] - : (t as any)[enCountry]) || enCountry; + const displayLabel = resolveCountryName(enCountry, locale); const cleanSlug = enCountry .toLowerCase() .replace(/[^a-z0-9]+/g, "_") @@ -231,7 +226,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const existing = existingByLabel.get(enCountry.toLowerCase()) || - existingByLabel.get(localizedLabel.toLowerCase()) || + existingByLabel.get(displayLabel.toLowerCase()) || existingByVal.get(cleanSlug) || existingById.get(generatedId.toLowerCase()); @@ -239,11 +234,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { if (seenIds.has(finalId)) return; seenIds.add(finalId); - const isPersian = locale === "fa" || locale === "fa-ir"; - const displayLabel = isPersian - ? (COUNTRIES_FA[idx] || localizedLabel) - : enCountry; - mergedOptions.push({ id: finalId, value: existing?.value ?? cleanSlug, @@ -295,9 +285,30 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }; }, [isCompact, isClosing, isOpen]); - const filteredOptions = options.filter((option) => - option.label.toLowerCase().includes(searchQuery.toLowerCase()), - ); + const filteredOptions = useMemo(() => { + const rawQ = searchQuery.trim(); + if (!rawQ) return options; + const lowerQ = rawQ.toLowerCase(); + const normQ = normalizeSearchString(rawQ); + + return options.filter((option) => { + if ( + option.label.toLowerCase().includes(lowerQ) || + String(option.value).toLowerCase().includes(lowerQ) || + option.id.toLowerCase().includes(lowerQ) + ) { + return true; + } + if (isCountryQuestion) { + const labelKeywords = getCountrySearchKeywords(option.label, locale); + if (labelKeywords.some((k) => k.includes(normQ))) return true; + + const valKeywords = getCountrySearchKeywords(String(option.value), locale); + if (valKeywords.some((k) => k.includes(normQ))) return true; + } + return false; + }); + }, [options, searchQuery, isCountryQuestion, locale]); const getCleanLabel = (optId: string) => { const normalized = String(optId || "").toLowerCase().trim(); @@ -325,7 +336,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const hasSelectedValue = isMulti ? selectedList.length > 0 - : Boolean(singleValue); + : Boolean(singleValue); const toggleMultiOption = (optionId: string) => { setLocalSelectedList((prev) => { @@ -362,22 +373,32 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }; const searchPlaceholder = - locale === "fa" + (t as any)["Search..."] || + (t as any)["Search"] || + (locale === "fa" ? "جستجو..." : locale === "ar" ? "بحث..." : locale === "tr" ? "Ara..." - : "Search..."; + : locale === "ru" + ? "Поиск..." + : "Search..."); const noResultsText = - locale === "fa" + (t as any)["No options found"] || + (locale === "fa" ? "موردی یافت نشد" : locale === "ar" ? "لم يتم العثور على نتائج" - : "No options found"; + : locale === "ru" + ? "Ничего не найдено" + : "No options found"); - const confirmText = t.Confirm || (isRtl ? "تایید" : "Confirm"); + const confirmText = + (t as any)["Confirm"] || + (t as any)["Подтверждать"] || + (locale === "ru" ? "Подтверждать" : isRtl ? "تایید" : "Confirm"); return (
{ if (e.key === "Escape") closeSheet(); }} @@ -459,7 +480,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {

- {question.title} + {(t as any)[question.title] || question.title}

{isMulti && Boolean(maxSelect) && ( @@ -468,12 +489,16 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { ? `سقف ${maxSelect} انتخاب تکمیل شد` : locale === "ar" ? `تم اختيار الحد الأقصى (${maxSelect})` - : `Maximum of ${maxSelect} selected`) + : locale === "ru" + ? `Выбрано максимум (${maxSelect})` + : `Maximum of ${maxSelect} selected`) : (locale === "fa" ? `انتخاب حداکثر ${maxSelect} مورد (${localSelectedList.length}/${maxSelect})` : locale === "ar" ? `اختر حتى ${maxSelect} عناصر (${localSelectedList.length}/${maxSelect})` - : `Select up to ${maxSelect} (${localSelectedList.length}/${maxSelect})`)} + : locale === "ru" + ? `Выберите до ${maxSelect} (${localSelectedList.length}/${maxSelect})` + : `Select up to ${maxSelect} (${localSelectedList.length}/${maxSelect})`)} )}
diff --git a/src/data/countries.test.ts b/src/data/countries.test.ts new file mode 100644 index 0000000..7641838 --- /dev/null +++ b/src/data/countries.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + COUNTRIES_EN, + COUNTRIES_FA, + getCountryIsoCode, + getCountryList, + isKnownCountry, + resolveCountryName, + filterCountryOptions, +} from "./countries"; + +describe("countries data and localization", () => { + it("resolves ISO codes correctly", () => { + expect(getCountryIsoCode("Afghanistan")).toBe("AF"); + expect(getCountryIsoCode("Albania")).toBe("AL"); + expect(getCountryIsoCode("Iran")).toBe("IR"); + expect(getCountryIsoCode("افغانستان")).toBe("AF"); + expect(getCountryIsoCode("ایران")).toBe("IR"); + expect(getCountryIsoCode("US")).toBe("US"); + }); + + it("resolves country names in Russian", () => { + expect(resolveCountryName("Afghanistan", "ru")).toBe("Афганистан"); + expect(resolveCountryName("Albania", "ru")).toBe("Албания"); + expect(resolveCountryName("Algeria", "ru")).toBe("Алжир"); + expect(resolveCountryName("Australia", "ru")).toBe("Австралия"); + expect(resolveCountryName("Germany", "ru")).toBe("Германия"); + }); + + it("resolves country names in Arabic", () => { + expect(resolveCountryName("Afghanistan", "ar")).toBe("أفغانستان"); + expect(resolveCountryName("Germany", "ar")).toBe("ألمانيا"); + }); + + it("resolves country names in Persian", () => { + expect(resolveCountryName("Afghanistan", "fa")).toBe("افغانستان"); + expect(resolveCountryName("Germany", "fa")).toBe("آلمان"); + expect(resolveCountryName("United States", "fa")).toBe("ایالات متحده آمریکا (US)"); + }); + + it("resolves country names in Turkish", () => { + expect(resolveCountryName("Afghanistan", "tr")).toBe("Afganistan"); + expect(resolveCountryName("Germany", "tr")).toBe("Almanya"); + }); + + it("getCountryList returns localized lists", () => { + const listFa = getCountryList("fa"); + expect(listFa[0]).toBe("افغانستان"); + + const listRu = getCountryList("ru"); + expect(listRu[0]).toBe("Афганистан"); + expect(listRu[1]).toBe("Албания"); + + const listEn = getCountryList("en"); + expect(listEn[0]).toBe("Afghanistan"); + }); + + it("identifies known countries", () => { + expect(isKnownCountry("Afghanistan")).toBe(true); + expect(isKnownCountry("افغانستان")).toBe(true); + expect(isKnownCountry("AF")).toBe(true); + expect(isKnownCountry("UnknownCountry123")).toBe(false); + }); + + it("filters country options across multiple languages and aliases", () => { + const listFa = getCountryList("fa"); + // Search in English on Persian list + const matchedIran = filterCountryOptions(listFa, "iran", "fa"); + expect(matchedIran[0]).toBe("ایران"); + + const matchedGermany = filterCountryOptions(listFa, "germany", "fa"); + expect(matchedGermany[0]).toBe("آلمان"); + + const matchedUsa = filterCountryOptions(listFa, "usa", "fa"); + expect(matchedUsa[0]).toBe("ایالات متحده آمریکا (US)"); + + const matchedTajikistan = filterCountryOptions(listFa, "tajikistan", "fa"); + expect(matchedTajikistan[0]).toBe("تاجیکستان"); + + // Search in Persian on English list + const listEn = getCountryList("en"); + const matchedIranEn = filterCountryOptions(listEn, "ایران", "en"); + expect(matchedIranEn[0]).toBe("Iran"); + + const matchedGermanyEn = filterCountryOptions(listEn, "آلمان", "en"); + expect(matchedGermanyEn[0]).toBe("Germany"); + }); +}); diff --git a/src/data/countries.ts b/src/data/countries.ts index 234d22b..29e5617 100644 --- a/src/data/countries.ts +++ b/src/data/countries.ts @@ -392,12 +392,349 @@ export const COUNTRIES_FA = [ "زیمبابوه", ]; -export function getCountryList(locale: string): string[] { - const normalized = String(locale || "en").toLowerCase(); - if (normalized === "fa" || normalized === "fa-ir") { - return COUNTRIES_FA; +export const COUNTRY_EN_TO_ISO: Record = { + "Afghanistan": "AF", + "Albania": "AL", + "Algeria": "DZ", + "Andorra": "AD", + "Angola": "AO", + "Antigua and Barbuda": "AG", + "Argentina": "AR", + "Armenia": "AM", + "Australia": "AU", + "Austria": "AT", + "Azerbaijan": "AZ", + "Bahamas": "BS", + "Bahrain": "BH", + "Bangladesh": "BD", + "Barbados": "BB", + "Belarus": "BY", + "Belgium": "BE", + "Belize": "BZ", + "Benin": "BJ", + "Bhutan": "BT", + "Bolivia": "BO", + "Bosnia and Herzegovina": "BA", + "Botswana": "BW", + "Brazil": "BR", + "Brunei": "BN", + "Bulgaria": "BG", + "Burkina Faso": "BF", + "Burundi": "BI", + "Cabo Verde": "CV", + "Cambodia": "KH", + "Cameroon": "CM", + "Canada": "CA", + "Central African Republic": "CF", + "Chad": "TD", + "Chile": "CL", + "China": "CN", + "Colombia": "CO", + "Comoros": "KM", + "Congo": "CG", + "Costa Rica": "CR", + "Croatia": "HR", + "Cuba": "CU", + "Cyprus": "CY", + "Czechia": "CZ", + "Democratic Republic of the Congo": "CD", + "Denmark": "DK", + "Djibouti": "DJ", + "Dominica": "DM", + "Dominican Republic": "DO", + "Ecuador": "EC", + "Egypt": "EG", + "El Salvador": "SV", + "Equatorial Guinea": "GQ", + "Eritrea": "ER", + "Estonia": "EE", + "Eswatini": "SZ", + "Ethiopia": "ET", + "Fiji": "FJ", + "Finland": "FI", + "France": "FR", + "Gabon": "GA", + "Gambia": "GM", + "Georgia": "GE", + "Germany": "DE", + "Ghana": "GH", + "Greece": "GR", + "Grenada": "GD", + "Guatemala": "GT", + "Guinea": "GN", + "Guinea-Bissau": "GW", + "Guyana": "GY", + "Haiti": "HT", + "Holy See": "VA", + "Honduras": "HN", + "Hungary": "HU", + "Iceland": "IS", + "India": "IN", + "Indonesia": "ID", + "Iran": "IR", + "Iraq": "IQ", + "Ireland": "IE", + "Italy": "IT", + "Ivory Coast": "CI", + "Jamaica": "JM", + "Japan": "JP", + "Jordan": "JO", + "Kazakhstan": "KZ", + "Kenya": "KE", + "Kiribati": "KI", + "Kuwait": "KW", + "Kyrgyzstan": "KG", + "Laos": "LA", + "Latvia": "LV", + "Lebanon": "LB", + "Lesotho": "LS", + "Liberia": "LR", + "Libya": "LY", + "Liechtenstein": "LI", + "Lithuania": "LT", + "Luxembourg": "LU", + "Madagascar": "MG", + "Malawi": "MW", + "Malaysia": "MY", + "Maldives": "MV", + "Mali": "ML", + "Malta": "MT", + "Marshall Islands": "MH", + "Mauritania": "MR", + "Mauritius": "MU", + "Mexico": "MX", + "Micronesia": "FM", + "Moldova": "MD", + "Monaco": "MC", + "Mongolia": "MN", + "Montenegro": "ME", + "Morocco": "MA", + "Mozambique": "MZ", + "Myanmar": "MM", + "Namibia": "NA", + "Nauru": "NR", + "Nepal": "NP", + "Netherlands": "NL", + "New Zealand": "NZ", + "Nicaragua": "NI", + "Niger": "NE", + "Nigeria": "NG", + "North Korea": "KP", + "North Macedonia": "MK", + "Norway": "NO", + "Oman": "OM", + "Pakistan": "PK", + "Palau": "PW", + "Palestine State": "PS", + "Panama": "PA", + "Papua New Guinea": "PG", + "Paraguay": "PY", + "Peru": "PE", + "Philippines": "PH", + "Poland": "PL", + "Portugal": "PT", + "Qatar": "QA", + "Romania": "RO", + "Russia": "RU", + "Rwanda": "RW", + "Saint Kitts and Nevis": "KN", + "Saint Lucia": "LC", + "Saint Vincent and the Grenadines": "VC", + "Samoa": "WS", + "San Marino": "SM", + "Sao Tome and Principe": "ST", + "Saudi Arabia": "SA", + "Senegal": "SN", + "Serbia": "RS", + "Seychelles": "SC", + "Sierra Leone": "SL", + "Singapore": "SG", + "Slovakia": "SK", + "Slovenia": "SI", + "Solomon Islands": "SB", + "Somalia": "SO", + "South Africa": "ZA", + "South Korea": "KR", + "South Sudan": "SS", + "Spain": "ES", + "Sri Lanka": "LK", + "Sudan": "SD", + "Suriname": "SR", + "Sweden": "SE", + "Switzerland": "CH", + "Syria": "SY", + "Tajikistan": "TJ", + "Tanzania": "TZ", + "Thailand": "TH", + "Timor-Leste": "TL", + "Togo": "TG", + "Tonga": "TO", + "Trinidad and Tobago": "TT", + "Tunisia": "TN", + "Turkey": "TR", + "Turkmenistan": "TM", + "Tuvalu": "TV", + "Uganda": "UG", + "Ukraine": "UA", + "United Arab Emirates (UAE)": "AE", + "United Arab Emirates": "AE", + "United Kingdom (UK)": "GB", + "United Kingdom": "GB", + "United States (US)": "US", + "United States": "US", + "United States of America": "US", + "Uruguay": "UY", + "Uzbekistan": "UZ", + "Vanuatu": "VU", + "Venezuela": "VE", + "Vietnam": "VN", + "Yemen": "YE", + "Zambia": "ZM", + "Zimbabwe": "ZW", +}; + +export const COUNTRY_ALIASES: Record = { + AE: ["UAE", "امارات", "امارات متحده عربی"], + GB: ["UK", "Great Britain", "England", "انگلیس", "بریتانیا", "انگلستان"], + US: ["USA", "America", "United States", "آمریکا", "ایالات متحده آمریکا"], + IR: ["Iran", "ایران", "جمهوری اسلامی ایران"], + SY: ["Syria", "سوریه"], + TR: ["Turkey", "Turkiye", "ترکیه"], + DE: ["Germany", "Deutschland", "آلمان"], + FR: ["France", "فرانسه"], + RU: ["Russia", "Russian Federation", "روسیه"], +}; + +export function normalizeSearchString(str: string): string { + return str + .toLowerCase() + .trim() + .replace(/[\u064B-\u065F\u0670]/g, "") + .replace(/[\u200B-\u200D\uFEFF]/g, "") + .replace(/[\s\-_.,/\\()]+/g, " "); +} + +export function getCountryIsoCode(countryOrCode: string | undefined | null): string | null { + if (!countryOrCode) return null; + const trimmed = countryOrCode.trim(); + if (!trimmed) return null; + + if (trimmed.length === 2 && /^[a-zA-Z]{2}$/.test(trimmed)) { + return trimmed.toUpperCase(); + } + + const enEntry = Object.entries(COUNTRY_EN_TO_ISO).find( + ([name]) => name.toLowerCase() === trimmed.toLowerCase(), + ); + if (enEntry) return enEntry[1]; + + const faIndex = COUNTRIES_FA.findIndex((c) => c === trimmed); + if (faIndex !== -1 && faIndex < COUNTRIES_EN.length) { + const enName = COUNTRIES_EN[faIndex]; + return COUNTRY_EN_TO_ISO[enName] || null; + } + + // Check aliases + const norm = normalizeSearchString(trimmed); + for (const [iso, aliases] of Object.entries(COUNTRY_ALIASES)) { + if (aliases.some((alias) => normalizeSearchString(alias) === norm)) { + return iso; + } + } + + return null; +} + +export function getCountrySearchKeywords( + countryOrCode: string | undefined | null, + locale?: string, +): string[] { + if (!countryOrCode) return []; + const iso = getCountryIsoCode(countryOrCode); + const keywords = new Set(); + + const addTerm = (term: string | undefined | null) => { + if (!term) return; + const norm = normalizeSearchString(term); + if (norm) keywords.add(norm); + }; + + addTerm(countryOrCode); + + if (iso) { + addTerm(iso); + const aliases = COUNTRY_ALIASES[iso] || []; + aliases.forEach(addTerm); + + try { + const enNames = new Intl.DisplayNames(["en"], { type: "region" }); + addTerm(enNames.of(iso)); + } catch {} + + try { + const faNames = new Intl.DisplayNames(["fa"], { type: "region" }); + addTerm(faNames.of(iso)); + } catch {} + + if (locale && locale !== "en" && locale !== "fa") { + try { + const locNames = new Intl.DisplayNames([locale], { type: "region" }); + addTerm(locNames.of(iso)); + } catch {} + } } - return COUNTRIES_EN; + + return Array.from(keywords); +} + +export function filterCountryOptions( + options: string[], + searchQuery: string, + locale?: string, +): string[] { + const rawQ = searchQuery.trim(); + if (!rawQ) return options; + + const normQ = normalizeSearchString(rawQ); + if (!normQ) return options; + + const matched = options.filter((option) => { + if (normalizeSearchString(option).includes(normQ)) { + return true; + } + const keywords = getCountrySearchKeywords(option, locale); + return keywords.some((k) => k.includes(normQ)); + }); + + return matched.sort((a, b) => { + const aNorm = normalizeSearchString(a); + const bNorm = normalizeSearchString(b); + + const aExact = aNorm === normQ; + const bExact = bNorm === normQ; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + + const aStarts = aNorm.startsWith(normQ); + const bStarts = bNorm.startsWith(normQ); + if (aStarts && !bStarts) return -1; + if (!aStarts && bStarts) return 1; + + const aKeywords = getCountrySearchKeywords(a, locale); + const bKeywords = getCountrySearchKeywords(b, locale); + + const aExactKey = aKeywords.includes(normQ); + const bExactKey = bKeywords.includes(normQ); + if (aExactKey && !bExactKey) return -1; + if (!aExactKey && bExactKey) return 1; + + const aStartsKey = aKeywords.some((k) => k.startsWith(normQ)); + const bStartsKey = bKeywords.some((k) => k.startsWith(normQ)); + if (aStartsKey && !bStartsKey) return -1; + if (!aStartsKey && bStartsKey) return 1; + + return a.localeCompare(b); + }); } export function resolveCountryName( @@ -408,45 +745,69 @@ export function resolveCountryName( const trimmed = countryOrCode.trim(); if (!trimmed) return ""; - const isFa = String(locale || "en").toLowerCase().startsWith("fa"); + const normLocale = String(locale || "en").toLowerCase(); + const isFa = normLocale === "fa" || normLocale === "fa-ir"; - // 1. If 2-letter ISO code (e.g. "DE", "IR", "US", "TJ") - if (trimmed.length === 2 && /^[a-zA-Z]{2}$/.test(trimmed)) { + const iso = getCountryIsoCode(trimmed); + + // 1. If Persian locale, prioritize curated COUNTRIES_FA + if (isFa) { + const faIndex = COUNTRIES_FA.findIndex((c) => c === trimmed); + if (faIndex !== -1) return COUNTRIES_FA[faIndex]; + + const enIndex = COUNTRIES_EN.findIndex( + (c) => c.toLowerCase() === trimmed.toLowerCase(), + ); + if (enIndex !== -1 && COUNTRIES_FA[enIndex]) { + return COUNTRIES_FA[enIndex]; + } + + if (iso) { + const idx = COUNTRIES_EN.findIndex( + (c) => COUNTRY_EN_TO_ISO[c] === iso, + ); + if (idx !== -1 && COUNTRIES_FA[idx]) { + return COUNTRIES_FA[idx]; + } + } + } + + // 2. Dynamic Intl.DisplayNames for the requested locale + if (iso) { try { - const displayNames = new Intl.DisplayNames([isFa ? "fa" : "en"], { + const displayNames = new Intl.DisplayNames([locale], { type: "region", }); - const name = displayNames.of(trimmed.toUpperCase()); + const name = displayNames.of(iso); if (name) return name; } catch {} } - // 2. If it matches an English country name in COUNTRIES_EN + // 3. Match from COUNTRIES_EN / COUNTRIES_FA indexes const enIndex = COUNTRIES_EN.findIndex( (c) => c.toLowerCase() === trimmed.toLowerCase(), ); if (enIndex !== -1) { - return isFa ? COUNTRIES_FA[enIndex] || COUNTRIES_EN[enIndex] : COUNTRIES_EN[enIndex]; + if (isFa) return COUNTRIES_FA[enIndex] || COUNTRIES_EN[enIndex]; + if (normLocale.startsWith("en")) return COUNTRIES_EN[enIndex]; } - // 3. If it matches a Persian country name in COUNTRIES_FA const faIndex = COUNTRIES_FA.findIndex((c) => c === trimmed); if (faIndex !== -1) { - return isFa ? COUNTRIES_FA[faIndex] : COUNTRIES_EN[faIndex] || COUNTRIES_FA[faIndex]; + if (isFa) return COUNTRIES_FA[faIndex]; + if (normLocale.startsWith("en")) return COUNTRIES_EN[faIndex] || COUNTRIES_FA[faIndex]; } - // 4. Try Intl.DisplayNames fallback if 2-3 chars - if (trimmed.length <= 3) { - try { - const displayNames = new Intl.DisplayNames([isFa ? "fa" : "en"], { - type: "region", - }); - const name = displayNames.of(trimmed.toUpperCase()); - if (name) return name; - } catch {} + return trimmed; +} + +export function getCountryList(locale: string = "en"): string[] { + const normLocale = String(locale || "en").toLowerCase(); + if (normLocale === "fa" || normLocale === "fa-ir") { + return COUNTRIES_FA; } - return trimmed; + return COUNTRIES_EN.map((enCountry) => resolveCountryName(enCountry, locale)); } export function isKnownCountry(countryOrCode: string | undefined | null): boolean { @@ -466,5 +827,5 @@ export function isKnownCountry(countryOrCode: string | undefined | null): boolea return true; } - return false; + return Boolean(getCountryIsoCode(trimmed)); } diff --git a/src/data/languages.test.ts b/src/data/languages.test.ts new file mode 100644 index 0000000..6cce5f6 --- /dev/null +++ b/src/data/languages.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + LANGUAGES_EN, + LANGUAGES_FA, + getLanguageCode, + getLanguageList, + resolveLanguageName, +} from "./languages"; + +describe("languages data and localization", () => { + it("resolves language codes correctly", () => { + expect(getLanguageCode("Afrikaans")).toBe("af"); + expect(getLanguageCode("Arabic")).toBe("ar"); + expect(getLanguageCode("Russian")).toBe("ru"); + expect(getLanguageCode("فارسی")).toBe("fa"); + expect(getLanguageCode("عربی")).toBe("ar"); + expect(getLanguageCode("en")).toBe("en"); + }); + + it("resolves language names in Russian", () => { + expect(resolveLanguageName("Afrikaans", "ru")).toBe("Африкаанс"); + expect(resolveLanguageName("Arabic", "ru")).toBe("Арабский"); + expect(resolveLanguageName("Russian", "ru")).toBe("Русский"); + expect(resolveLanguageName("English", "ru")).toBe("Английский"); + expect(resolveLanguageName("Persian", "ru")).toBe("Персидский"); + expect(resolveLanguageName("Other", "ru")).toBe("Другое"); + }); + + it("resolves language names in Arabic", () => { + expect(resolveLanguageName("Arabic", "ar")).toBe("العربية"); + expect(resolveLanguageName("English", "ar")).toBe("الإنجليزية"); + expect(resolveLanguageName("Other", "ar")).toBe("أخرى"); + }); + + it("resolves language names in Persian", () => { + expect(resolveLanguageName("Arabic", "fa")).toBe("عربی"); + expect(resolveLanguageName("English", "fa")).toBe("انگلیسی"); + expect(resolveLanguageName("Russian", "fa")).toBe("روسی"); + expect(resolveLanguageName("Other", "fa")).toBe("سایر"); + }); + + it("resolves language names in Turkish", () => { + expect(resolveLanguageName("Arabic", "tr")).toBe("Arapça"); + expect(resolveLanguageName("English", "tr")).toBe("İngilizce"); + expect(resolveLanguageName("Russian", "tr")).toBe("Rusça"); + expect(resolveLanguageName("Other", "tr")).toBe("Diğer"); + }); + + it("getLanguageList returns localized lists", () => { + const listFa = getLanguageList("fa"); + expect(listFa[0]).toBe("آذربایجانی"); + + const listRu = getLanguageList("ru"); + expect(listRu[0]).toBe("Африкаанс"); + + const listEn = getLanguageList("en"); + expect(listEn[0]).toBe("Afrikaans"); + }); +}); diff --git a/src/data/languages.ts b/src/data/languages.ts index 6765184..7444472 100644 --- a/src/data/languages.ts +++ b/src/data/languages.ts @@ -153,6 +153,85 @@ export const LANGUAGES_FA = [ "سایر", ]; +export const LANGUAGE_EN_TO_CODE: Record = { + Afrikaans: "af", + Albanian: "sq", + Amharic: "am", + Arabic: "ar", + Armenian: "hy", + Azerbaijani: "az", + Balochi: "bal", + Bengali: "bn", + Bosnian: "bs", + Bulgarian: "bg", + Burmese: "my", + Catalan: "ca", + Chinese: "zh", + Croatian: "hr", + Czech: "cs", + Danish: "da", + Dutch: "nl", + English: "en", + Estonian: "et", + Finnish: "fi", + French: "fr", + Georgian: "ka", + German: "de", + Greek: "el", + Gujarati: "gu", + Hausa: "ha", + Hebrew: "he", + Hindi: "hi", + Hungarian: "hu", + Icelandic: "is", + Indonesian: "id", + Irish: "ga", + Italian: "it", + Japanese: "ja", + Kashmiri: "ks", + Kazakh: "kk", + Khmer: "km", + Korean: "ko", + Kurdish: "ku", + Kyrgyz: "ky", + Latvian: "lv", + Lithuanian: "lt", + Macedonian: "mk", + Malay: "ms", + Maltese: "mt", + Mongolian: "mn", + Nepali: "ne", + Norwegian: "no", + Pashto: "ps", + Persian: "fa", + "Persian (Farsi)": "fa", + Polish: "pl", + Portuguese: "pt", + Punjabi: "pa", + Romanian: "ro", + Russian: "ru", + Serbian: "sr", + Sinhala: "si", + Slovak: "sk", + Slovenian: "sl", + Somali: "so", + Spanish: "es", + Swahili: "sw", + Swedish: "sv", + Tajik: "tg", + Tamil: "ta", + Telugu: "te", + Thai: "th", + Turkish: "tr", + Turkmen: "tk", + Ukrainian: "uk", + Urdu: "ur", + "Urdu Roman (Latin)": "ur", + Uzbek: "uz", + Vietnamese: "vi", + Other: "other", +}; + export const LANGUAGE_EN_TO_FA: Record = { Afrikaans: "آفریکانس", Albanian: "آلبانیایی", @@ -232,10 +311,97 @@ export const LANGUAGE_EN_TO_FA: Record = { Other: "سایر", }; -export function getLanguageList(locale: string): string[] { - const normalized = String(locale || "en").toLowerCase(); - if (normalized === "fa" || normalized === "fa-ir") { +export function getLanguageCode(langOrCode: string | undefined | null): string | null { + if (!langOrCode) return null; + const trimmed = langOrCode.trim(); + if (!trimmed) return null; + + if (trimmed.length === 2 && /^[a-zA-Z]{2}$/.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const enEntry = Object.entries(LANGUAGE_EN_TO_CODE).find( + ([name]) => name.toLowerCase() === trimmed.toLowerCase(), + ); + if (enEntry) return enEntry[1]; + + const faEntry = Object.entries(LANGUAGE_EN_TO_FA).find( + ([, faName]) => faName.trim() === trimmed, + ); + if (faEntry) { + return LANGUAGE_EN_TO_CODE[faEntry[0]] || null; + } + + return null; +} + +export function resolveLanguageName( + langOrCode: string | undefined | null, + locale: string = "fa", +): string { + if (!langOrCode) return ""; + const trimmed = langOrCode.trim(); + if (!trimmed) return ""; + + const normLocale = String(locale || "en").toLowerCase(); + const isFa = normLocale === "fa" || normLocale === "fa-ir"; + + if (trimmed.toLowerCase() === "other" || trimmed === "سایر") { + if (isFa) return "سایر"; + if (normLocale.startsWith("ar")) return "أخرى"; + if (normLocale.startsWith("ru")) return "Другое"; + if (normLocale.startsWith("tr")) return "Diğer"; + if (normLocale.startsWith("fr")) return "Autre"; + if (normLocale.startsWith("es")) return "Otro"; + if (normLocale.startsWith("de")) return "Andere"; + if (normLocale.startsWith("zh")) return "其他"; + return "Other"; + } + + // 1. If Persian locale, prioritize curated LANGUAGE_EN_TO_FA + if (isFa) { + if (LANGUAGES_FA.includes(trimmed)) return trimmed; + + const enKey = Object.keys(LANGUAGE_EN_TO_FA).find( + (k) => k.toLowerCase() === trimmed.toLowerCase(), + ); + if (enKey && LANGUAGE_EN_TO_FA[enKey]) { + return LANGUAGE_EN_TO_FA[enKey]; + } + } + + const langCode = getLanguageCode(trimmed); + + // 2. Dynamic Intl.DisplayNames + if (langCode && langCode !== "other") { + try { + const displayNames = new Intl.DisplayNames([locale], { + type: "language", + }); + const name = displayNames.of(langCode); + if (name) { + return name.charAt(0).toUpperCase() + name.slice(1); + } + } catch {} + } + + const enIndex = LANGUAGES_EN.findIndex( + (l) => l.toLowerCase() === trimmed.toLowerCase(), + ); + if (enIndex !== -1) { + const enName = LANGUAGES_EN[enIndex]; + if (isFa && LANGUAGE_EN_TO_FA[enName]) return LANGUAGE_EN_TO_FA[enName]; + if (normLocale.startsWith("en")) return enName; + } + + return trimmed; +} + +export function getLanguageList(locale: string = "en"): string[] { + const normLocale = String(locale || "en").toLowerCase(); + if (normLocale === "fa" || normLocale === "fa-ir") { return LANGUAGES_FA; } - return LANGUAGES_EN; + + return LANGUAGES_EN.map((enLang) => resolveLanguageName(enLang, locale)); }