"use client"; 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 type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { Button } from "./button"; import { ExplanationUiFont } from "./explanation-ui-font"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { registerCompactQuestionSheet } from "./question-viewport-coordinator"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; const EXIT_ANIMATION_MS = 300; const EMPTY_ARRAY: string[] = []; export type QuestionSheetProps = { question: QuestionField; disabled?: boolean; }; export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const { dictionary: t, locale } = useI18n(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const rawValue = getAnswerValue(question); const isMulti = Array.isArray(rawValue) || question.type === "checkbox" || (question.extras?.range && question.extras.range[1] > 1); const selectedList = useMemo(() => { if (Array.isArray(rawValue)) return rawValue; if (typeof rawValue === "string" && rawValue) return [rawValue]; return EMPTY_ARRAY; }, [rawValue]); const singleValue = typeof rawValue === "string" ? rawValue : ""; const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const listRef = useRef(null); const sheetRef = useRef(null); const [localSelectedList, setLocalSelectedList] = useState(selectedList); const localSelectedListRef = useRef(selectedList); useEffect(() => { localSelectedListRef.current = localSelectedList; }, [localSelectedList]); useEffect(() => { if (!isOpen) { setLocalSelectedList(selectedList); localSelectedListRef.current = selectedList; } }, [isOpen, selectedList]); const closeSheet = useCallback(() => { setIsClosing(true); if (isMulti) { const currentList = localSelectedListRef.current; setAnswerValue( question, currentList.length > 0 ? currentList : null, ); } window.setTimeout(() => { setIsOpen(false); setIsClosing(false); setSearchQuery(""); }, EXIT_ANIMATION_MS); }, [isMulti, question, setAnswerValue]); const openSheet = useCallback(() => { if (disabled) return; setLocalSelectedList(selectedList); localSelectedListRef.current = selectedList; setIsOpen(true); setIsClosing(false); }, [disabled, selectedList]); useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet }); // Handle escape key useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { closeSheet(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen, closeSheet]); const isLanguageQuestion = question.id?.toLowerCase().includes("language") || question.id?.toLowerCase().includes("mother_tongue") || question.id?.toLowerCase().includes("other_languages") || question.title?.toLowerCase().includes("language") || question.title?.toLowerCase().includes("tongue") || question.title?.includes("زبان") || question.ui_config?.dataset === "languages"; const isCountryQuestion = question.id?.toLowerCase().includes("nationality") || question.id?.toLowerCase().includes("citizenship") || question.id?.toLowerCase().includes("country") || question.id?.toLowerCase().includes("birthplace") || question.id?.toLowerCase().includes("residence") || question.title?.toLowerCase().includes("nationality") || question.title?.toLowerCase().includes("citizenship") || question.title?.toLowerCase().includes("country") || question.title?.includes("ملیت") || question.title?.includes("تابعیت") || question.title?.includes("کشور") || question.title?.includes("سکونت") || question.ui_config?.dataset === "countries" || question.ui_config?.dataset === "nationalities"; const options = useMemo(() => { const rawOptions = question.options || []; if (isLanguageQuestion) { const existingByLabel = new Map( rawOptions.map((opt) => [opt.label.toLowerCase().trim(), opt]), ); const existingByVal = new Map( rawOptions.map((opt) => [String(opt.value).toLowerCase().trim(), opt]), ); const existingById = new Map( rawOptions.map((opt) => [opt.id.toLowerCase().trim(), opt]), ); const mergedOptions: typeof rawOptions = []; const seenIds = new Set(); LANGUAGES_EN.forEach((enLang, idx) => { const localizedLabel = (locale === "fa" || locale === "fa-ir" ? (LANGUAGE_EN_TO_FA[enLang] || LANGUAGES_FA[idx]) : (t as any)[enLang]) || enLang; const cleanSlug = enLang .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); const generatedId = `${question.id}.${cleanSlug}`; const existing = existingByLabel.get(enLang.toLowerCase()) || existingByLabel.get(localizedLabel.toLowerCase()) || existingByVal.get(cleanSlug) || existingById.get(generatedId.toLowerCase()); const finalId = existing?.id || generatedId; if (seenIds.has(finalId)) return; seenIds.add(finalId); const isPersian = locale === "fa" || locale === "fa-ir"; const displayLabel = isPersian ? (LANGUAGE_EN_TO_FA[enLang] || localizedLabel) : enLang; mergedOptions.push({ id: finalId, value: existing?.value ?? cleanSlug, label: displayLabel, order: existing?.order ?? idx, }); }); rawOptions.forEach((opt) => { if (!seenIds.has(opt.id)) { seenIds.add(opt.id); mergedOptions.push(opt); } }); return mergedOptions; } if (isCountryQuestion) { const existingByLabel = new Map( rawOptions.map((opt) => [opt.label.toLowerCase().trim(), opt]), ); const existingByVal = new Map( rawOptions.map((opt) => [String(opt.value).toLowerCase().trim(), opt]), ); const existingById = new Map( rawOptions.map((opt) => [opt.id.toLowerCase().trim(), opt]), ); const mergedOptions: typeof rawOptions = []; 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 cleanSlug = enCountry .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); const generatedId = `${question.id}.${cleanSlug}`; const existing = existingByLabel.get(enCountry.toLowerCase()) || existingByLabel.get(localizedLabel.toLowerCase()) || existingByVal.get(cleanSlug) || existingById.get(generatedId.toLowerCase()); const finalId = existing?.id || generatedId; 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, label: displayLabel, order: existing?.order ?? idx, }); }); rawOptions.forEach((opt) => { if (!seenIds.has(opt.id)) { seenIds.add(opt.id); mergedOptions.push(opt); } }); return mergedOptions; } return rawOptions; }, [question, isLanguageQuestion, isCountryQuestion, locale, t]); const COMPACT_OPTIONS_MAX = 6; const isCompact = options.length <= COMPACT_OPTIONS_MAX; const showSearch = !question.extras?.noSearch && options.length > COMPACT_OPTIONS_MAX; useEffect(() => { if (!isOpen || isClosing || !isCompact) return; let unregister: (() => void) | undefined; const frame = window.requestAnimationFrame(() => { if (sheetRef.current) { unregister = registerCompactQuestionSheet(sheetRef.current); } }); return () => { window.cancelAnimationFrame(frame); unregister?.(); }; }, [isCompact, isClosing, isOpen]); const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()), ); const getCleanLabel = (optId: string) => { const normalized = String(optId || "").toLowerCase().trim(); const opt = options.find( (o) => o.id === optId || o.id.toLowerCase() === normalized || String(o.value).toLowerCase() === normalized || o.id.endsWith(`.${normalized}`), ); if (!opt) return optId; return opt.label.split(" - ")[0]; }; const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; const defaultPlaceholder = isRtl ? "انتخاب کنید" : "Select"; const displayLabel = isMulti ? selectedList.length > 0 ? selectedList.map(getCleanLabel).join(", ") : question.extras?.placeHolder || defaultPlaceholder : singleValue ? getCleanLabel(singleValue) : question.extras?.placeHolder || defaultPlaceholder; const hasSelectedValue = isMulti ? selectedList.length > 0 : Boolean(singleValue); const toggleMultiOption = (optionId: string) => { setLocalSelectedList((prev) => { let nextValue: string[]; if (prev.includes(optionId)) { nextValue = prev.filter((v) => v !== optionId); } else { nextValue = [...prev, optionId]; } localSelectedListRef.current = nextValue; return nextValue; }); }; const handleConfirmMulti = () => { const currentList = localSelectedListRef.current; setAnswerValue( question, currentList.length > 0 ? currentList : null, ); closeSheet(); }; const handleSelectSingle = (optionId: string) => { setAnswerValue(question, optionId); closeSheet(); }; const searchPlaceholder = locale === "fa" ? "جستجو..." : locale === "ar" ? "بحث..." : locale === "tr" ? "Ara..." : "Search..."; const noResultsText = locale === "fa" ? "موردی یافت نشد" : locale === "ar" ? "لم يتم العثور على نتائج" : "No options found"; const confirmText = t.Confirm || (isRtl ? "تایید" : "Confirm"); return (
{/* Select Trigger Button (Input style) */} {/* The active field moves up while the options sheet enters from below. */} {isOpen && createPortal(
{ if (e.key === "Escape") closeSheet(); }} onClick={(event) => { if (event.target === event.currentTarget) closeSheet(); }} >

{question.title}

{/* Search Bar */} {showSearch && (
setSearchQuery(e.target.value)} placeholder={searchPlaceholder} className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" /> {searchQuery ? ( ) : null}
)} {/* Options List */}
event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()} onTouchEnd={(event) => event.stopPropagation()} className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3" > {filteredOptions.length > 0 ? ( filteredOptions.map((option) => { const isSelected = isMulti ? localSelectedList.includes(option.id) : singleValue === option.id; return ( ); }) ) : (
{noResultsText}
)}
{/* Bottom Actions for Multi-Select */} {isMulti && (
)}
, document.body, )}
); } export default QuestionSheet;