diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 5cc6df5..c610f37 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -520,7 +520,7 @@ export default function QuestionDetailClient({ @@ -565,7 +565,7 @@ export default function QuestionDetailClient({ @@ -601,7 +601,7 @@ export default function QuestionDetailClient({ @@ -780,7 +780,7 @@ export default function QuestionDetailClient({ @@ -961,7 +961,7 @@ export default function QuestionDetailClient({ diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx index 0865cc0..175b557 100644 --- a/src/components/Componentes/question-sheet.test.tsx +++ b/src/components/Componentes/question-sheet.test.tsx @@ -744,6 +744,105 @@ describe("QuestionSheet component", () => { expect(screen.getByRole("button", { name: "Арабский" })).toBeDefined(); expect(screen.getByRole("button", { name: "Русский" })).toBeDefined(); }); + + it("renders search bar and full list of languages for mother_tongue even with initial partial options", async () => { + const { useI18n } = await import("@/translations/provider"); + (useI18n as any).mockReturnValue({ + locale: "fa", + dictionary: { "Mother Tongue": "زبان مادری", Confirm: "تایید" }, + }); + + const languageQuestion = { + id: "personal_identity.mother_tongue", + title: "Native Language / Mother Tongue", + type: "dropdown", + order: 1, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "انتخاب کنید", noSearch: true }, + ui_config: { noSearch: true }, + options: [ + { id: "opt_fa", value: "persian", label: "Persian (Farsi)", order: 1 }, + { id: "opt_en", value: "english", label: "English", order: 2 }, + { id: "opt_other", value: "other", label: "Other", order: 3 }, + ], + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button")); + + // Verify search input is present + const searchInput = screen.getByPlaceholderText("جستجو..."); + expect(searchInput).toBeDefined(); + + // Verify options have full languages list (e.g. آلمانی, فرانسوی, etc.) + expect(screen.getByRole("button", { name: "آلمانی" })).toBeDefined(); + expect(screen.getByRole("button", { name: "فرانسوی" })).toBeDefined(); + expect(screen.getByRole("button", { name: "فارسی" })).toBeDefined(); + + // Test search filtering + fireEvent.change(searchInput, { target: { value: "آلمان" } }); + expect(screen.getByRole("button", { name: "آلمانی" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "فرانسوی" })).toBeNull(); + }); + + it("renders search bar and filters country options for current_nationality_citizenship", async () => { + const { useI18n } = await import("@/translations/provider"); + (useI18n as any).mockReturnValue({ + locale: "en", + dictionary: { Confirm: "Confirm" }, + }); + + const nationalityQuestion = { + id: "personal_identity.current_nationality_citizenship", + title: "Current Nationality / Citizenship", + type: "dropdown", + order: 2, + required: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "Select one option", noSearch: true }, + ui_config: { noSearch: true }, + options: [ + { id: "opt_af", value: "afghanistan", label: "Afghanistan", order: 1 }, + { id: "opt_al", value: "albania", label: "Albania", order: 2 }, + { id: "opt_dz", value: "algeria", label: "Algeria", order: 3 }, + ], + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button")); + + // Verify search input is present + const searchInput = screen.getByPlaceholderText("Search..."); + expect(searchInput).toBeDefined(); + + // Verify country options are rendered + expect(screen.getByRole("button", { name: "Afghanistan" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Albania" })).toBeDefined(); + + // Test search filtering by country name + fireEvent.change(searchInput, { target: { value: "Albania" } }); + expect(screen.getByRole("button", { name: "Albania" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Afghanistan" })).toBeNull(); + }); }); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index 79d398c..0aa7739 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -9,7 +9,12 @@ import { normalizeSearchString, getCountrySearchKeywords, } from "@/data/countries"; -import { LANGUAGES_EN, LANGUAGES_FA, resolveLanguageName } from "@/data/languages"; +import { + LANGUAGES_EN, + LANGUAGES_FA, + resolveLanguageName, + getLanguageSearchKeywords, +} from "@/data/languages"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { Button } from "./button"; @@ -21,6 +26,7 @@ import { useSheetScrollLock } from "./use-sheet-scroll-lock"; const EXIT_ANIMATION_MS = 200; const EMPTY_ARRAY: string[] = []; +const COMPACT_OPTIONS_MAX = 6; export type QuestionSheetProps = { question: QuestionField; @@ -122,32 +128,47 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { question.id?.toLowerCase().includes("residence_status") || question.id?.toLowerCase().includes("status") || question.id?.toLowerCase().includes("responsibility") || - question.extras?.noSearch === true || - question.ui_config?.noSearch === true; + question.id?.toLowerCase().includes("difference") || + question.id?.toLowerCase().includes("impact"); + + const isLanguageQuestion = Boolean( + question.ui_config?.dataset === "languages" || + question.id?.endsWith(".mother_tongue") || + question.id?.endsWith(".native_language") || + question.id?.endsWith(".other_languages") || + question.id?.endsWith(".languages") || + question.id?.endsWith(".language") || + (!question.id?.includes("impact") && + !question.id?.includes("difference") && + Boolean( + question.title?.toLowerCase().includes("mother tongue") || + question.title?.toLowerCase().includes("native language") || + question.title?.toLowerCase() === "language" || + question.title?.toLowerCase() === "languages" || + question.title?.includes("زبان مادری") || + question.title?.includes("زبان‌های مسلط") || + question.title?.includes("زبان های مسلط") + )) + ); - const isLanguageQuestion = - !isExcludedFromAutoDatasets && - (question.ui_config?.dataset === "languages" || - question.id?.endsWith(".mother_tongue") || - question.id?.endsWith(".native_language") || - question.id?.endsWith(".other_languages") || - (Boolean(question.title?.toLowerCase().includes("language")) && - !question.options?.length)); - - const isCountryQuestion = + const isCountryQuestion = Boolean( !isExcludedFromAutoDatasets && (question.ui_config?.dataset === "countries" || question.ui_config?.dataset === "nationalities" || - question.id?.endsWith(".nationality") || - question.id?.endsWith(".citizenship") || - question.id?.endsWith(".second_nationality") || - (Boolean( - question.id?.endsWith(".birthplace") || - question.id?.endsWith(".current_residence") || - question.title?.toLowerCase().includes("nationality") || - question.title?.toLowerCase().includes("citizenship"), - ) && - !question.options?.length)); + question.id?.includes("nationality") || + question.id?.includes("citizenship") || + question.id?.endsWith(".birthplace") || + question.id?.endsWith(".current_residence") || + question.id?.endsWith(".country") || + Boolean( + question.title?.toLowerCase().includes("nationality") || + question.title?.toLowerCase().includes("citizenship") || + question.title?.toLowerCase().includes("country") || + question.title?.includes("تابعیت") || + question.title?.includes("ملیت") || + question.title?.includes("کشور") + )) + ); const options = useMemo(() => { const rawOptions = question.options || []; @@ -165,6 +186,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const mergedOptions: typeof rawOptions = []; const seenIds = new Set(); + const seenLabels = new Set(); LANGUAGES_EN.forEach((enLang, idx) => { const displayLabel = resolveLanguageName(enLang, locale); @@ -181,8 +203,10 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { existingById.get(generatedId.toLowerCase()); const finalId = existing?.id || generatedId; - if (seenIds.has(finalId)) return; + const normLabel = displayLabel.toLowerCase().trim(); + if (seenIds.has(finalId) || seenLabels.has(normLabel)) return; seenIds.add(finalId); + seenLabels.add(normLabel); mergedOptions.push({ id: finalId, @@ -193,12 +217,27 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }); rawOptions.forEach((opt) => { - if (!seenIds.has(opt.id)) { + const normLabel = opt.label.toLowerCase().trim(); + if (!seenIds.has(opt.id) && !seenLabels.has(normLabel)) { seenIds.add(opt.id); + seenLabels.add(normLabel); mergedOptions.push(opt); } }); + // Keep "Other" / "سایر" at the very end + const otherIndex = mergedOptions.findIndex( + (opt) => + opt.value === "other" || + opt.id.endsWith(".other") || + opt.label.toLowerCase() === "other" || + opt.label === "سایر", + ); + if (otherIndex !== -1 && otherIndex !== mergedOptions.length - 1) { + const [otherOpt] = mergedOptions.splice(otherIndex, 1); + mergedOptions.push(otherOpt); + } + return mergedOptions; } @@ -259,13 +298,16 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }, [question, isLanguageQuestion, isCountryQuestion, locale, t]); const noSearch = Boolean( - question.extras?.noSearch === true || - question.ui_config?.noSearch === true || - question.id?.toLowerCase().includes("responsibility"), + !isLanguageQuestion && + !isCountryQuestion && + options.length <= COMPACT_OPTIONS_MAX && + ( + question.extras?.noSearch === true || + question.ui_config?.noSearch === true || + question.id?.toLowerCase().includes("responsibility") + ) ); - const COMPACT_OPTIONS_MAX = 6; - const isCompact = options.length <= COMPACT_OPTIONS_MAX || noSearch; const showSearch = !noSearch && options.length > COMPACT_OPTIONS_MAX; @@ -299,6 +341,13 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { ) { return true; } + if (isLanguageQuestion) { + const labelKeywords = getLanguageSearchKeywords(option.label, locale); + if (labelKeywords.some((k) => k.includes(normQ))) return true; + + const valKeywords = getLanguageSearchKeywords(String(option.value), locale); + if (valKeywords.some((k) => k.includes(normQ))) return true; + } if (isCountryQuestion) { const labelKeywords = getCountrySearchKeywords(option.label, locale); if (labelKeywords.some((k) => k.includes(normQ))) return true; @@ -308,7 +357,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { } return false; }); - }, [options, searchQuery, isCountryQuestion, locale]); + }, [options, searchQuery, isLanguageQuestion, isCountryQuestion, locale]); const getCleanLabel = (optId: string) => { const normalized = String(optId || "").toLowerCase().trim(); @@ -317,9 +366,16 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { o.id === optId || o.id.toLowerCase() === normalized || String(o.value).toLowerCase() === normalized || + o.label.toLowerCase().trim() === normalized || o.id.endsWith(`.${normalized}`), ); - if (!opt) return optId; + if (!opt) { + if (isLanguageQuestion) { + const resolved = resolveLanguageName(optId, locale); + if (resolved && resolved !== optId) return resolved; + } + return optId; + } return opt.label.split(" - ")[0]; }; @@ -593,8 +649,13 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { {filteredOptions.length > 0 ? ( filteredOptions.map((option) => { const isSelected = isMulti - ? localSelectedList.includes(option.id) - : singleValue === option.id; + ? localSelectedList.includes(option.id) || + localSelectedList.includes(option.value) || + localSelectedList.includes(option.id.split(".").pop() || "") + : singleValue === option.id || + singleValue === option.value || + singleValue === option.id.split(".").pop() || + option.id.endsWith(`.${singleValue}`); const isOptionDisabled = isMulti && diff --git a/src/data/languages.test.ts b/src/data/languages.test.ts index 6cce5f6..d5e3b2c 100644 --- a/src/data/languages.test.ts +++ b/src/data/languages.test.ts @@ -5,6 +5,8 @@ import { getLanguageCode, getLanguageList, resolveLanguageName, + getLanguageSearchKeywords, + filterLanguageOptions, } from "./languages"; describe("languages data and localization", () => { @@ -56,4 +58,26 @@ describe("languages data and localization", () => { const listEn = getLanguageList("en"); expect(listEn[0]).toBe("Afrikaans"); }); + + it("extracts language search keywords and filters options correctly", () => { + const faKeywords = getLanguageSearchKeywords("Persian (Farsi)", "fa"); + expect(faKeywords).toContain("farsi"); + expect(faKeywords).toContain("فارسی"); + + const options = ["Persian (Farsi)", "English", "Arabic", "German", "Other"]; + + // Search in Persian + expect(filterLanguageOptions(options, "فارسی", "fa")).toEqual(["Persian (Farsi)"]); + expect(filterLanguageOptions(options, "انگلیسی", "fa")).toEqual(["English"]); + expect(filterLanguageOptions(options, "آلمانی", "fa")).toEqual(["German"]); + + // Search in English + expect(filterLanguageOptions(options, "persian", "en")).toEqual(["Persian (Farsi)"]); + expect(filterLanguageOptions(options, "farsi", "en")).toEqual(["Persian (Farsi)"]); + expect(filterLanguageOptions(options, "ger", "en")).toEqual(["German"]); + + // Search for other + expect(filterLanguageOptions(options, "سایر", "fa")).toEqual(["Other"]); + expect(filterLanguageOptions(options, "other", "en")).toEqual(["Other"]); + }); }); diff --git a/src/data/languages.ts b/src/data/languages.ts index 7444472..70dba9d 100644 --- a/src/data/languages.ts +++ b/src/data/languages.ts @@ -48,7 +48,6 @@ export const LANGUAGES_EN = [ "Nepali", "Norwegian", "Pashto", - "Persian", "Persian (Farsi)", "Polish", "Portuguese", @@ -405,3 +404,125 @@ export function getLanguageList(locale: string = "en"): string[] { return LANGUAGES_EN.map((enLang) => resolveLanguageName(enLang, locale)); } + +export const LANGUAGE_ALIASES: Record = { + fa: ["persian", "farsi", "فارسی", "پارسی", "فارسی دری", "دری", "تاجیکی"], + en: ["english", "انگلیسی", "اینگلیسی", "انگلیش"], + ar: ["arabic", "عربی", "عربى"], + tr: ["turkish", "ترکی", "ترکی استانبولی", "turkce", "türkçe"], + ur: ["urdu", "اردو", "اردو رومن", "roman urdu"], + fr: ["french", "فرانسوی", "فرانسه", "francais", "français"], + de: ["german", "آلمانی", "deutsch"], + es: ["spanish", "اسپانیایی", "espanol", "español"], + ru: ["russian", "روسی", "русский"], + zh: ["chinese", "چینی", "ماندارین", "mandarin"], + hi: ["hindi", "هندی"], + az: ["azerbaijani", "azeri", "آذربایجانی", "ترکی آذربایجانی", "ترکی آذری"], + ku: ["kurdish", "کردی", "کوردی", "سورانی", "کورمانجی"], + bal: ["balochi", "بلوچی"], + ps: ["pashto", "پشتو", "پختو"], + it: ["italian", "ایتالیایی"], + ja: ["japanese", "ژاپنی"], + ko: ["korean", "کره ای", "کره‌ای"], + pt: ["portuguese", "پرتغالی"], + nl: ["dutch", "هلندی"], + sv: ["swedish", "سوئدی"], + pl: ["polish", "لهستانی"], + other: ["other", "سایر", "دیگر", "سایر زبان‌ها", "سایر زبان ها"], +}; + +export function normalizeLanguageSearch(str: string): string { + return str + .toLowerCase() + .trim() + .replace(/[\u064B-\u065F\u0670]/g, "") + .replace(/[\u200B-\u200D\uFEFF]/g, "") + .replace(/ي/g, "ی") + .replace(/ك/g, "ک") + .replace(/[\s\-_.,/\\()]+/g, " "); +} + +export function getLanguageSearchKeywords( + langOrCode: string | undefined | null, + locale?: string, +): string[] { + if (!langOrCode) return []; + const keywords = new Set(); + + const addTerm = (term: string | undefined | null) => { + if (!term) return; + const norm = normalizeLanguageSearch(term); + if (norm) keywords.add(norm); + }; + + addTerm(langOrCode); + + const code = getLanguageCode(langOrCode); + if (code) { + addTerm(code); + const aliases = LANGUAGE_ALIASES[code] || []; + aliases.forEach(addTerm); + + const enKey = Object.keys(LANGUAGE_EN_TO_CODE).find( + (k) => LANGUAGE_EN_TO_CODE[k] === code, + ); + if (enKey) { + addTerm(enKey); + if (LANGUAGE_EN_TO_FA[enKey]) { + addTerm(LANGUAGE_EN_TO_FA[enKey]); + } + } + + try { + const enNames = new Intl.DisplayNames(["en"], { type: "language" }); + addTerm(enNames.of(code)); + } catch {} + + try { + const faNames = new Intl.DisplayNames(["fa"], { type: "language" }); + addTerm(faNames.of(code)); + } catch {} + + if (locale && locale !== "en" && locale !== "fa") { + try { + const locNames = new Intl.DisplayNames([locale], { type: "language" }); + addTerm(locNames.of(code)); + } catch {} + } + } + + const trimmedLower = langOrCode.toLowerCase().trim(); + if ( + trimmedLower === "other" || + trimmedLower === "سایر" || + code === "other" + ) { + const otherAliases = ["other", "سایر", "دیگر", "دیگری", "غیره"]; + otherAliases.forEach(addTerm); + } + + return Array.from(keywords); +} + +export function filterLanguageOptions( + options: string[], + searchQuery: string, + locale?: string, +): string[] { + const rawQ = searchQuery.trim(); + if (!rawQ) return options; + const normQ = normalizeLanguageSearch(rawQ); + const lowerQ = rawQ.toLowerCase(); + + return options.filter((option) => { + if ( + option.toLowerCase().includes(lowerQ) || + normalizeLanguageSearch(option).includes(normQ) + ) { + return true; + } + const keywords = getLanguageSearchKeywords(option, locale); + return keywords.some((k) => k.includes(normQ)); + }); +} + diff --git a/src/lib/marriage-profile-contract.ts b/src/lib/marriage-profile-contract.ts index 3390f60..844ac54 100644 --- a/src/lib/marriage-profile-contract.ts +++ b/src/lib/marriage-profile-contract.ts @@ -7,7 +7,7 @@ import type { MarriageRecommendedPlan, } from "@/hooks/marriage/types"; -export const REQUIRED_PROFILE_SECTION_COUNT = 10; +export const REQUIRED_PROFILE_SECTION_COUNT = 9; /** * Normalizes a single MarriageField from various potential backend or Flutter bridge formats. diff --git a/src/lib/schema-adapter.ts b/src/lib/schema-adapter.ts index 5ae2056..be7366c 100644 --- a/src/lib/schema-adapter.ts +++ b/src/lib/schema-adapter.ts @@ -224,10 +224,24 @@ export function mapBackendQuestionToFrontend( if (!uiConfig.picker_type) uiConfig.picker_type = uiConfig.source || "file_system"; } + let questionType = bq.type; + if ( + questionType === "text" && + (bq.id?.includes("contact_number") || + bq.id?.includes("phone") || + bq.ui_config?.input_type === "phone" || + Boolean(bq.title?.toLowerCase().includes("contact number")) || + Boolean(bq.title?.toLowerCase().includes("phone")) || + Boolean(bq.title?.includes("شماره تماس")) || + Boolean(bq.title?.includes("شماره تلفن"))) + ) { + questionType = "phone"; + } + return { id: bq.id, title: bq.title || "Untitled", - type: bq.type, + type: questionType, order: bq.order !== undefined ? bq.order : index, required: bq.is_required !== undefined ? bq.is_required : bq.required, baseRequired: bq.required,