You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

637 lines
23 KiB

"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<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList);
const localSelectedListRef = useRef<string[]>(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<string>();
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<string>();
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 (
<div
className={[
"relative flex w-full flex-col gap-2.5 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
{/* Select Trigger Button (Input style) */}
<button
type="button"
disabled={disabled}
onClick={openSheet}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
className={[
"text-[15px] font-medium truncate",
hasSelectedValue ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
{displayLabel}
</span>
<svg
aria-hidden="true"
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* The active field moves up while the options sheet enters from below. */}
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-transparent"
role="dialog"
aria-label={question.title}
onKeyDown={(e) => {
if (e.key === "Escape") closeSheet();
}}
onClick={(event) => {
if (event.target === event.currentTarget) closeSheet();
}}
>
<section
ref={sheetRef}
className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isCompact && !showSearch
? "h-auto max-h-[82svh]"
: "h-[82svh] min-h-[82svh] max-h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{question.title}
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Search Bar */}
{showSearch && (
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
type="text"
name="sheet_search_field"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck="false"
aria-autocomplete="none"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
>
</button>
) : null}
</div>
</div>
)}
{/* Options List */}
<div
ref={listRef}
onTouchStart={(event) => 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 (
<button
key={option.id}
type="button"
onClick={() => {
if (isMulti) {
toggleMultiOption(option.id);
} else {
handleSelectSingle(option.id);
}
}}
className={[
"flex min-h-12 w-full items-start gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")}
>
{/* Indicator Icon */}
{isMulti ? (
<div
className={[
"size-[22px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5 flex items-center justify-center",
isSelected
? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
>
{isSelected && (
<svg
aria-hidden="true"
width="12"
height="10"
viewBox="0 0 12 10"
fill="none"
>
<path
d="M1.5 5L4.5 8L10.5 2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
) : (
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 mt-0.5 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
)}
<span className="text-[15px] leading-snug flex-1">
{option.label.includes(" - ") ? (
(() => {
const parts = option.label.split(" - ");
const title = parts[0];
const description = parts.slice(1).join(" - ");
return (
<span className="flex flex-col gap-1 text-start">
<span className="font-bold text-[#181818]">
{title}
</span>
<ExplanationUiFont>
{description}
</ExplanationUiFont>
</span>
);
})()
) : (
<span
className={
isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]"
}
>
{option.label}
</span>
)}
</span>
</button>
);
})
) : (
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)}
</div>
{/* Bottom Actions for Multi-Select */}
{isMulti && (
<div className="border-t border-[#F2F4F7] bg-white p-4 pt-3 pb-[calc(14px+env(safe-area-inset-bottom))]">
<Button
type="button"
variant="default"
onClick={handleConfirmMulti}
className="w-full h-[50px] rounded-[14px] text-[15px] font-bold shadow-[0_8px_20px_rgba(240,68,91,0.25)] cursor-pointer"
>
{localSelectedList.length > 0
? `${confirmText} (${localSelectedList.length})`
: confirmText}
</Button>
</div>
)}
</section>
</div>,
document.body,
)}
</div>
);
}
export default QuestionSheet;