Browse Source

feat: implement phone input component with Flutter-based geolocation, country-specific formatting, and validation testing

master
Muhammad A. Ghorbani 3 weeks ago
parent
commit
970d277231
  1. 27
      src/components/Componentes/question-birthplace.tsx
  2. 48
      src/components/Componentes/question-phone.test.tsx
  3. 102
      src/components/Componentes/question-phone.tsx
  4. 76
      src/components/Componentes/question-sheet.test.tsx
  5. 95
      src/components/Componentes/question-sheet.tsx
  6. 88
      src/data/countries.test.ts
  7. 411
      src/data/countries.ts
  8. 59
      src/data/languages.test.ts
  9. 174
      src/data/languages.ts

27
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 (
<div

48
src/components/Componentes/question-phone.test.tsx

@ -335,4 +335,52 @@ describe("QuestionPhone IP country detection and shimmer", () => {
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(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
// 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();
});
});
});

102
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<string>();
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 (
<div

76
src/components/Componentes/question-sheet.test.tsx

@ -669,6 +669,82 @@ describe("QuestionSheet component", () => {
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[countryQuestion]}>
<QuestionSheet question={countryQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[languageQuestion]}>
<QuestionSheet question={languageQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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();
});
});

95
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<string>();
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<string>();
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 (
<div
@ -438,7 +459,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
isClosing ? "flutter-scrim-exit" : "flutter-scrim",
].join(" ")}
role="dialog"
aria-label={question.title}
aria-label={(t as any)[question.title] || question.title}
onKeyDown={(e) => {
if (e.key === "Escape") closeSheet();
}}
@ -459,7 +480,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<div className="flex-1 min-w-0 flex flex-col text-start">
<h3 className="flex-1 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{question.title}
{(t as any)[question.title] || question.title}
</h3>
{isMulti && Boolean(maxSelect) && (
<span className="text-xs text-[#667085] mt-0.5">
@ -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})`)}
</span>
)}
</div>

88
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");
});
});

411
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<string, string> = {
"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<string, string[]> = {
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<string>();
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));
}

59
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");
});
});

174
src/data/languages.ts

@ -153,6 +153,85 @@ export const LANGUAGES_FA = [
"سایر",
];
export const LANGUAGE_EN_TO_CODE: Record<string, string> = {
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<string, string> = {
Afrikaans: "آفریکانس",
Albanian: "آلبانیایی",
@ -232,10 +311,97 @@ export const LANGUAGE_EN_TO_FA: Record<string, string> = {
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));
}
Loading…
Cancel
Save