+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const closeSheet = useCallback(() => {
+ if (isClosingRef.current) return;
+ isClosingRef.current = true;
+ setIsClosing(true);
+ window.setTimeout(() => {
+ setIsClosing(false);
+ isClosingRef.current = false;
+ onClose?.();
+ }, EXIT_ANIMATION_MS);
+ }, [onClose]);
+
+ useEffect(() => {
+ if (isOpen && mounted) {
+ isClosingRef.current = false;
+ setIsClosing(false);
+ sheetSizeRef.current = SHEET_INITIAL_SIZE;
+ if (sheetRef.current) {
+ sheetRef.current.style.setProperty(
+ "--sheet-size",
+ SHEET_INITIAL_SIZE.toFixed(4),
+ );
+ }
+ }
+ }, [isOpen, mounted]);
+
+ useSheetScrollLock(isOpen, { onBack: closeSheet });
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ closeSheet();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [isOpen, closeSheet]);
+
+ // Drag-to-resize parity with Flutter DraggableScrollableSheet:
+ // initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
+ useEffect(() => {
+ if (!isOpen || isClosing) return;
+
+ const content = contentRef.current;
+ const sheet = sheetRef.current;
+ const header = headerRef.current;
+ if (!sheet) return;
+
+ let active = false;
+ let engaged = false;
+ let closing = false;
+ let isHeaderDrag = false;
+ let startX = 0;
+ let startY = 0;
+ let lastY = 0;
+
+ const contentOverflows = () =>
+ content ? content.scrollHeight > content.clientHeight + 1 : false;
+
+ const resize = (deltaPx: number) => {
+ const viewportHeight = window.innerHeight || 1;
+ const nextSize = Math.min(
+ SHEET_MAX_SIZE,
+ Math.max(
+ SHEET_MIN_SIZE,
+ sheetSizeRef.current + deltaPx / viewportHeight,
+ ),
+ );
+ sheetSizeRef.current = nextSize;
+ sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
+ };
+
+ const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => {
+ if (event.touches.length !== 1) {
+ active = false;
+ return;
+ }
+ active = true;
+ engaged = fromHeader;
+ isHeaderDrag = fromHeader;
+ startX = event.touches[0].clientX;
+ startY = event.touches[0].clientY;
+ lastY = startY;
+ };
+
+ const handleTouchMove = (event: TouchEvent) => {
+ if (!active || closing || event.touches.length !== 1) return;
+ const touch = event.touches[0];
+ const deltaY = lastY - touch.clientY; // finger up = grow sheet
+ const pulled = startY - touch.clientY; // cumulative distance
+ lastY = touch.clientY;
+
+ if (!engaged) {
+ if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
+ if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
+ const atTop = !content || content.scrollTop <= 0;
+ const canGrow =
+ pulled > 0 &&
+ atTop &&
+ contentOverflows() &&
+ sheetSizeRef.current < SHEET_MAX_SIZE - 0.001;
+ const canShrink = pulled < 0 && atTop;
+ if (!canGrow && !canShrink) return;
+ engaged = true;
+ }
+
+ const atTop = !content || content.scrollTop <= 0;
+ const size = sheetSizeRef.current;
+ const consume = isHeaderDrag
+ ? true
+ : deltaY > 0
+ ? atTop && size < SHEET_MAX_SIZE - 0.001
+ : atTop;
+
+ if (!consume) return; // at ceiling or scrolling inside content
+ if (event.cancelable) {
+ event.preventDefault();
+ }
+
+ if (deltaY > 0) {
+ resize(deltaY);
+ } else if (size <= SHEET_MIN_SIZE + 0.001) {
+ closing = true; // shouldCloseOnMinExtent: true
+ closeSheet();
+ } else {
+ resize(deltaY);
+ }
+ };
+
+ const handleTouchEnd = () => {
+ active = false;
+ engaged = false;
+ isHeaderDrag = false;
+ };
+
+ const handleWheel = (event: WheelEvent) => {
+ if (closing) return;
+ const delta = event.deltaY;
+ const atTop = !content || content.scrollTop <= 0;
+ const size = sheetSizeRef.current;
+ const consume =
+ delta > 0
+ ? atTop && contentOverflows() && size < SHEET_MAX_SIZE - 0.001
+ : atTop;
+ if (!consume) return;
+ if (event.cancelable) {
+ event.preventDefault();
+ }
+
+ if (delta > 0) {
+ resize(delta);
+ } else if (size <= SHEET_MIN_SIZE + 0.001) {
+ closing = true;
+ closeSheet();
+ } else {
+ resize(delta);
+ }
+ };
+
+ const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
+ const onContentTouchStart = (e: TouchEvent) => handleTouchStart(e, false);
+
+ if (header) {
+ header.addEventListener("touchstart", onHeaderTouchStart, {
+ passive: true,
+ });
+ }
+ if (content) {
+ content.addEventListener("touchstart", onContentTouchStart, {
+ passive: true,
+ });
+ content.addEventListener("touchmove", handleTouchMove, { passive: false });
+ content.addEventListener("touchend", handleTouchEnd, { passive: true });
+ content.addEventListener("touchcancel", handleTouchEnd, { passive: true });
+ content.addEventListener("wheel", handleWheel, { passive: false });
+ }
+ sheet.addEventListener("touchmove", handleTouchMove, { passive: false });
+ sheet.addEventListener("touchend", handleTouchEnd, { passive: true });
+ sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
+
+ return () => {
+ if (header) {
+ header.removeEventListener("touchstart", onHeaderTouchStart);
+ }
+ if (content) {
+ content.removeEventListener("touchstart", onContentTouchStart);
+ content.removeEventListener("touchmove", handleTouchMove);
+ content.removeEventListener("touchend", handleTouchEnd);
+ content.removeEventListener("touchcancel", handleTouchEnd);
+ content.removeEventListener("wheel", handleWheel);
+ }
+ sheet.removeEventListener("touchmove", handleTouchMove);
+ sheet.removeEventListener("touchend", handleTouchEnd);
+ sheet.removeEventListener("touchcancel", handleTouchEnd);
+ };
+ }, [isOpen, isClosing, mounted, closeSheet]);
+
+ if (!mounted || (!isOpen && !isClosing)) return null;
+
+ const isRtl =
+ locale === "fa" ||
+ locale === "ar" ||
+ locale === "ur" ||
+ locale === "he" ||
+ locale === "ks";
+
+ return createPortal(
+
{
+ if (event.target === event.currentTarget) closeSheet();
+ }}
+ >
+
+ {/* Drag handle & Header */}
+
+
+
+
+
+ {termsTitle}
+
+
+
+
+
+ {/* Scrollable Terms Content */}
+ event.stopPropagation()}
+ onTouchMove={(event) => event.stopPropagation()}
+ onTouchEnd={(event) => event.stopPropagation()}
+ className="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4 text-start text-[13px] leading-[1.6] text-[#4C4C4C]"
+ >
{TERMS_SECTIONS.map((section, idx) => (
-
- {(t as Record)[section.category] || section.category}
+
+ {(t as Record)[section.category] ||
+ section.category}
-
+
{section.items.map((item, itemIdx) => {
const translatedTitle = item.title
? (t as Record)[item.title] || item.title
@@ -266,11 +530,11 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
return (
-
{translatedTitle && (
-
+
{translatedTitle}:{" "}
)}
-
+
{translatedDesc}
@@ -280,15 +544,21 @@ export function TermsSheet({ isOpen, onClose }: TermsSheetProps) {
))}
- }
- buttons={({ close }) => (
-
- )}
- onClose={onClose}
- className="text-start"
- />
+
+ {/* Bottom Confirm Action */}
+
+
+
+
+
,
+ document.body,
);
}
diff --git a/src/hooks/marriage/use-form-schema.ts b/src/hooks/marriage/use-form-schema.ts
index 6a83e85..94c38ae 100644
--- a/src/hooks/marriage/use-form-schema.ts
+++ b/src/hooks/marriage/use-form-schema.ts
@@ -10,6 +10,8 @@ export interface FormOption {
value: string;
label: string;
order: number;
+ /** مانعةالجمع — از دیتابیس (ui_config.exclusive_options) توسط بکاند محاسبه میشود */
+ is_exclusive?: boolean;
}
export interface FormQuestion {
diff --git a/src/lib/multi-select-helper.test.ts b/src/lib/multi-select-helper.test.ts
new file mode 100644
index 0000000..9bc4eb1
--- /dev/null
+++ b/src/lib/multi-select-helper.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from "vitest";
+import {
+ isExclusiveOption,
+ resolveMultiOptionToggle,
+ type MultiSelectOption,
+} from "./multi-select-helper";
+
+describe("multi-select-helper", () => {
+ const options: MultiSelectOption[] = [
+ { id: "opt1", value: "football", label: "Football" },
+ { id: "opt2", value: "swimming", label: "Swimming" },
+ { id: "opt3", value: "running", label: "Running" },
+ { id: "opt_none", value: "none", label: "None of the above", is_exclusive: true },
+ ];
+
+ describe("isExclusiveOption", () => {
+ it("identifies explicit is_exclusive property", () => {
+ expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true);
+ expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false);
+ });
+
+ it("accepts the explicit flag and rejects everything else (SSOT: backend flag only)", () => {
+ expect(isExclusiveOption({ id: "custom", is_exclusive: true })).toBe(true);
+ expect(isExclusiveOption({ id: "custom", is_exclusive: false })).toBe(false);
+ expect(isExclusiveOption({ id: "custom" })).toBe(false);
+ });
+
+ it("returns false for missing/empty/string-only input (no flag = not exclusive)", () => {
+ expect(isExclusiveOption("")).toBe(false);
+ expect(isExclusiveOption("test.none")).toBe(false);
+ });
+
+ it("does NOT guess from canonical values or slug suffixes (SSOT: backend flag only)", () => {
+ expect(isExclusiveOption({ id: "test.none", value: "none" })).toBe(false);
+ expect(isExclusiveOption({ id: "test.no_pets", value: "no_pets" })).toBe(false);
+ expect(
+ isExclusiveOption(
+ "spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult",
+ ),
+ ).toBe(false);
+ expect(
+ isExclusiveOption({
+ id: "spouse_criteria.appearance_dealbreakers_aspects.no_appearance_feature_alone_makes_difficult",
+ value: "no_appearance_feature_alone_makes_difficult",
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe("resolveMultiOptionToggle", () => {
+ it("adds a regular option when none was selected", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: [],
+ optionId: "opt1",
+ options,
+ });
+ expect(res).toEqual(["opt1"]);
+ });
+
+ it("adds multiple regular options sequentially", () => {
+ const res1 = resolveMultiOptionToggle({
+ currentSelected: ["opt1"],
+ optionId: "opt2",
+ options,
+ });
+ expect(res1).toEqual(["opt1", "opt2"]);
+
+ const res2 = resolveMultiOptionToggle({
+ currentSelected: ["opt1", "opt2"],
+ optionId: "opt3",
+ options,
+ });
+ expect(res2).toEqual(["opt1", "opt2", "opt3"]);
+ });
+
+ it("deselects a regular option when toggled again", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: ["opt1", "opt2"],
+ optionId: "opt1",
+ options,
+ });
+ expect(res).toEqual(["opt2"]);
+ });
+
+ it("clears ALL regular options when an exclusive option is selected", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: ["opt1", "opt2", "opt3"],
+ optionId: "opt_none",
+ options,
+ });
+ expect(res).toEqual(["opt_none"]);
+ });
+
+ it("deselects the exclusive option when toggled again", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: ["opt_none"],
+ optionId: "opt_none",
+ options,
+ });
+ expect(res).toEqual([]);
+ });
+
+ it("clears the exclusive option when a regular option is clicked", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: ["opt_none"],
+ optionId: "opt1",
+ options,
+ });
+ expect(res).toEqual(["opt1"]);
+ });
+
+ it("enforces maxSelect for regular options without exclusive conflict", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: ["opt1", "opt2"],
+ optionId: "opt3",
+ options,
+ maxSelect: 2,
+ });
+ expect(res).toEqual(["opt1", "opt2"]); // Capped at 2
+ });
+
+ it("allows selecting exclusive option even when regular options reach maxSelect", () => {
+ const res = resolveMultiOptionToggle({
+ currentSelected: ["opt1", "opt2"],
+ optionId: "opt_none",
+ options,
+ maxSelect: 2,
+ });
+ expect(res).toEqual(["opt_none"]);
+ });
+ });
+});
diff --git a/src/lib/multi-select-helper.ts b/src/lib/multi-select-helper.ts
new file mode 100644
index 0000000..55f8dfe
--- /dev/null
+++ b/src/lib/multi-select-helper.ts
@@ -0,0 +1,76 @@
+export type MultiSelectOption = {
+ id: string;
+ value?: string | number;
+ label?: string;
+ is_exclusive?: boolean;
+};
+
+/**
+ * Determine if an option is mutually exclusive (مانعةالجمع) with the other
+ * options of its question.
+ *
+ * ⚠️ Single Source of Truth: exclusivity is configured in the live database
+ * (via the admin dashboard) and delivered by the backend as the
+ * `is_exclusive` flag on every option. No hardcoded slug/suffix heuristics.
+ */
+export function isExclusiveOption(option: MultiSelectOption | string): boolean {
+ if (!option || typeof option !== "object") return false;
+ return option.is_exclusive === true;
+}
+
+/**
+ * Resolves toggling an option in a multi-select context with automatic mutual exclusion (مانعةالجمع).
+ *
+ * Behavior:
+ * 1. If an exclusive option is selected:
+ * - All other selected options are automatically cleared (deselected).
+ * - Only the exclusive option remains selected.
+ * 2. If an exclusive option is deselected:
+ * - It is removed, leaving an empty selection.
+ * 3. If a regular (non-exclusive) option is selected while an exclusive option was active:
+ * - The exclusive option is automatically cleared (deselected).
+ * - The new regular option is selected.
+ * 4. Respects maxSelect limit for regular options.
+ */
+export function resolveMultiOptionToggle({
+ currentSelected,
+ optionId,
+ options = [],
+ maxSelect,
+}: {
+ currentSelected: string[];
+ optionId: string;
+ options?: MultiSelectOption[];
+ maxSelect?: number;
+}): string[] {
+ const targetOption = options.find((o) => o.id === optionId) || { id: optionId };
+ const isExclusive = isExclusiveOption(targetOption);
+ const isAlreadySelected = currentSelected.includes(optionId);
+
+ // Case 1: Toggling an exclusive option
+ if (isExclusive) {
+ if (isAlreadySelected) {
+ return [];
+ }
+ return [optionId];
+ }
+
+ // Case 2: Toggling a regular option that is already selected -> remove it
+ if (isAlreadySelected) {
+ return currentSelected.filter((id) => id !== optionId);
+ }
+
+ // Case 3: Adding a regular option that was NOT selected:
+ // First, filter out any exclusive option(s) from current selection
+ const cleanSelected = currentSelected.filter((id) => {
+ const opt = options.find((o) => o.id === id) || { id };
+ return !isExclusiveOption(opt);
+ });
+
+ // Check maxSelect limit
+ if (maxSelect && cleanSelected.length >= maxSelect) {
+ return cleanSelected;
+ }
+
+ return [...cleanSelected, optionId];
+}
diff --git a/src/lib/schema-adapter.ts b/src/lib/schema-adapter.ts
index be7366c..a2a8729 100644
--- a/src/lib/schema-adapter.ts
+++ b/src/lib/schema-adapter.ts
@@ -142,6 +142,7 @@ export type QuestionField = {
value: string | number;
label: string;
order: number;
+ is_exclusive?: boolean;
}[];
};
@@ -274,6 +275,7 @@ export function mapBackendQuestionToFrontend(
.map((o) => ({
...o,
label: o.label || o.value || "",
+ is_exclusive: Boolean(o.is_exclusive),
})),
};
}