Browse Source

feat: implement phone input component with geo-based auto-detection, loading indicators, and supporting schema logic.

Dev
parent
commit
cc42f3d353
  1. BIN
      simplified_profile_icons_02_to_11.zip
  2. 70
      src/app/questions-list/[slug]/question-detail-client.tsx
  3. 23
      src/app/questions-list/page.tsx
  4. 26
      src/app/questions-list/sections-request.tsx
  5. 41
      src/components/Componentes/loading-border-spinner.tsx
  6. 5
      src/components/Componentes/loading-icon-spinner.tsx
  7. 5
      src/components/Componentes/loading-select-spinner.tsx
  8. 17
      src/components/Componentes/question-answer-storage.tsx
  9. 23
      src/components/Componentes/question-card.tsx
  10. 214
      src/components/Componentes/question-phone.test.tsx
  11. 390
      src/components/Componentes/question-phone.tsx
  12. 70
      src/components/Componentes/question-sheet.tsx
  13. 138
      src/components/Componentes/question-slider.test.tsx
  14. 214
      src/components/Componentes/question-slider.tsx
  15. 134
      src/components/Componentes/schema-question-flow.integration.test.tsx
  16. 39
      src/components/Componentes/slider-slide-two.test.tsx
  17. 31
      src/components/Componentes/slider-slide-two.tsx
  18. 200
      src/components/Componentes/ui-icon.tsx
  19. 5
      src/data/languages.ts
  20. 4
      src/hooks/marriage/use-form-schema.ts
  21. 1
      src/hooks/marriage/use-marriage-config.ts
  22. 203
      src/lib/conditional-rules.test.ts
  23. 304
      src/lib/conditional-rules.ts
  24. 22
      src/lib/schema-adapter-overview.test.ts
  25. 113
      src/lib/schema-adapter.ts
  26. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/02_contact_residence_family_communication.png
  27. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/03_physical_appearance_health.png
  28. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/04_education_career_economic_status.png
  29. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/05_family_background.png
  30. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/06_marital_status_marriage_history_children.png
  31. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/07_beliefs_lifestyle_personal_boundaries.png
  32. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/08_future_spouse_criteria_red_lines.png
  33. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/09_identity_verification_documents.png
  34. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/10_personality_test.png
  35. BIN
      temp_extracted_icons/simplified_profile_icons_02_to_11/11_glasser_5_needs_test.png

BIN
simplified_profile_icons_02_to_11.zip

70
src/app/questions-list/[slug]/question-detail-client.tsx

@ -42,6 +42,8 @@ import {
mapBackendSectionToFrontend, mapBackendSectionToFrontend,
type QuestionField, type QuestionField,
} from "@/lib/schema-adapter"; } from "@/lib/schema-adapter";
import { isQuestionVisible, isQuestionRequired } from "@/lib/conditional-rules";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -76,29 +78,54 @@ function getQuestionStorageKey(slug: string) {
} }
function QuestionFlowWrapper({ function QuestionFlowWrapper({
visibleQuestions,
questions,
itemSlug, itemSlug,
dobQuestion,
continueLabel, continueLabel,
questionsListHref, questionsListHref,
}: { }: {
visibleQuestions: QuestionField[];
questions: QuestionField[];
itemSlug: string; itemSlug: string;
dobQuestion?: QuestionField;
requiredQuestionsCount: number;
continueLabel: string; continueLabel: string;
questionsListHref: string; questionsListHref: string;
}) { }) {
const { getAnswerValue } = useQuestionAnswers();
const { getAnswerValue, answers } = useQuestionAnswers();
const { data: profile } = useMarriageProfileQuery();
const userContext = useMemo(
() => ({
gender: profile?.gender,
age: profile?.age,
}),
[profile?.gender, profile?.age],
);
// dynamicQuestions is now exactly what the backend gives as visible
const dynamicQuestions = visibleQuestions;
const dynamicQuestions = useMemo(() => {
return questions
.filter((q) => isQuestionVisible(q, answers, userContext))
.map((q) => ({
...q,
required: isQuestionRequired(q, answers, userContext),
}));
}, [questions, answers, userContext]);
const requiredCount = useMemo( const requiredCount = useMemo(
() => dynamicQuestions.filter((q) => q.required).length, () => dynamicQuestions.filter((q) => q.required).length,
[dynamicQuestions], [dynamicQuestions],
); );
const dobQuestion = useMemo(
() =>
dynamicQuestions.find(
(question) =>
question.ui_config?.isDob === true || question.type === "date",
) ||
questions.find(
(question) =>
question.ui_config?.isDob === true || question.type === "date",
),
[dynamicQuestions, questions],
);
return ( return (
<QuestionSectionFlow <QuestionSectionFlow
key={itemSlug} key={itemSlug}
@ -318,23 +345,7 @@ export default function QuestionDetailClient({
})); }));
}, [glasserQuery.data]); }, [glasserQuery.data]);
const visibleQuestions = useMemo(() => {
if (!item) {
return [];
}
return item.questions
.filter((question) => (question as any).isVisible !== false)
.map((question) => ({
...question,
required: Boolean(question.required),
}));
}, [item]);
const requiredQuestionsCount = useMemo(
() => visibleQuestions.filter((q) => q.required).length,
[visibleQuestions],
);
useEffect(() => { useEffect(() => {
if (!isSchemaLoading && !isSchemaError && !item) { if (!isSchemaLoading && !isSchemaError && !item) {
@ -781,18 +792,13 @@ export default function QuestionDetailClient({
); );
} }
const dobQuestion = visibleQuestions.find(
(question) =>
question.ui_config?.isDob === true || question.type === "date",
);
return ( return (
<> <>
<PageBackground disabled /> <PageBackground disabled />
<QuestionAnswersProvider <QuestionAnswersProvider
slug={item.slug} slug={item.slug}
questions={visibleQuestions}
questions={item.questions}
locale={locale} locale={locale}
> >
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
@ -824,10 +830,8 @@ export default function QuestionDetailClient({
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0"> <div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
<QuestionFlowWrapper <QuestionFlowWrapper
visibleQuestions={visibleQuestions}
questions={item.questions}
itemSlug={item.slug} itemSlug={item.slug}
dobQuestion={dobQuestion}
requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel} continueLabel={continueLabel}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}
/> />

23
src/app/questions-list/page.tsx

@ -87,6 +87,7 @@ export default function QuestionsListPage() {
}, },
}); });
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
const [isTermsOpen, setIsTermsOpen] = useState(false);
const [selectedSection, setSelectedSection] = const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null); useState<QuestionListItem | null>(null);
const questionListItems = useMemo( const questionListItems = useMemo(
@ -506,8 +507,15 @@ export default function QuestionsListPage() {
icon="document" icon="document"
iconLabel={t["Support"]} iconLabel={t["Support"]}
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
onClick={() => setIsTermsOpen(true)}
disableHelpModal
/> />
</header> </header>
<SectionsRequest
sections={overview?.sections}
isOpen={isTermsOpen || undefined}
onClose={() => setIsTermsOpen(false)}
/>
<div className="relative mt-4 space-y-5"> <div className="relative mt-4 space-y-5">
<RequiredStepsCard <RequiredStepsCard
@ -586,8 +594,15 @@ export default function QuestionsListPage() {
icon="document" icon="document"
iconLabel={t["Support"]} iconLabel={t["Support"]}
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
onClick={() => setIsTermsOpen(true)}
disableHelpModal
/> />
</header> </header>
<SectionsRequest
sections={overview?.sections}
isOpen={isTermsOpen || undefined}
onClose={() => setIsTermsOpen(false)}
/>
<DataErrorState <DataErrorState
onRetry={() => { onRetry={() => {
@ -669,7 +684,11 @@ export default function QuestionsListPage() {
className="text-left" className="text-left"
/> />
) : null} ) : null}
<SectionsRequest sections={overview?.sections} />
<SectionsRequest
sections={overview?.sections}
isOpen={isTermsOpen || undefined}
onClose={() => setIsTermsOpen(false)}
/>
<PageBackground disabled /> <PageBackground disabled />
<main <main
@ -706,6 +725,8 @@ export default function QuestionsListPage() {
icon="document" icon="document"
iconLabel={t["Support"]} iconLabel={t["Support"]}
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
onClick={() => setIsTermsOpen(true)}
disableHelpModal
/> />
</header> </header>

26
src/app/questions-list/sections-request.tsx

@ -25,10 +25,15 @@ const FIRST_ENTRY_TERMS = [
export default function SectionsRequest({ export default function SectionsRequest({
sections, sections,
isOpen: controlledIsOpen,
onClose: controlledOnClose,
}: { }: {
sections: FormOverviewSection[] | undefined;
sections?: FormOverviewSection[] | undefined;
isOpen?: boolean;
onClose?: () => void;
}) { }) {
const [hasSeenSheet, setHasSeenSheet] = useState(true); const [hasSeenSheet, setHasSeenSheet] = useState(true);
const [isAutoOpenDismissed, setIsAutoOpenDismissed] = useState(false);
const hasNoProgression = useMemo(() => { const hasNoProgression = useMemo(() => {
if (!sections?.length) { if (!sections?.length) {
@ -42,8 +47,6 @@ export default function SectionsRequest({
); );
}, [sections]); }, [sections]);
const isOpen = Boolean(sections) && hasNoProgression && !hasSeenSheet;
useEffect(() => { useEffect(() => {
try { try {
const seenLocal = const seenLocal =
@ -57,20 +60,23 @@ export default function SectionsRequest({
} }
}, []); }, []);
useEffect(() => {
if (!isOpen) {
return;
}
const isAutoOpen =
Boolean(sections) && hasNoProgression && !hasSeenSheet && !isAutoOpenDismissed;
const isSheetOpen =
controlledIsOpen !== undefined ? controlledIsOpen : isAutoOpen;
const handleClose = () => {
setIsAutoOpenDismissed(true);
try { try {
window.localStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true"); window.localStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true");
window.sessionStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true"); window.sessionStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true");
} catch (e) { } catch (e) {
console.warn("Storage is not accessible:", e); console.warn("Storage is not accessible:", e);
} }
}, [isOpen]);
controlledOnClose?.();
};
if (!isOpen) {
if (!isSheetOpen) {
return null; return null;
} }
@ -104,7 +110,9 @@ export default function SectionsRequest({
Got it Got it
</Button> </Button>
)} )}
onClose={handleClose}
className="text-left" className="text-left"
/> />
); );
} }

41
src/components/Componentes/loading-border-spinner.tsx

@ -1,12 +1,38 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export interface LoadingBorderSpinnerProps extends ComponentProps<"span"> {
size?: "xs" | "sm" | "md" | "lg" | "xl";
variant?: "primary" | "rose" | "blue" | "muted" | "white" | "current";
}
const sizeClasses: Record<NonNullable<LoadingBorderSpinnerProps["size"]>, string> = {
xs: "size-3.5 border-[1.5px]",
sm: "size-4 border-2",
md: "size-5 border-2",
lg: "size-8 border-[3px]",
xl: "size-10 border-4",
};
const variantClasses: Record<NonNullable<LoadingBorderSpinnerProps["variant"]>, string> = {
primary: "border-[#FC4F63]/25 border-t-[#FC4F63]",
rose: "border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400",
blue: "border-blue-500/25 border-t-blue-500 dark:border-blue-400/20 dark:border-t-blue-400",
muted: "border-muted-foreground/25 border-t-muted-foreground",
white: "border-white/30 border-t-white",
current: "border-current/25 border-t-current",
};
export function LoadingBorderSpinner({ export function LoadingBorderSpinner({
size,
variant,
className = "", className = "",
...props ...props
}: ComponentProps<"span">) {
}: LoadingBorderSpinnerProps) {
const hasBorder = className.split(" ").some((c) => c.startsWith("border-")); const hasBorder = className.split(" ").some((c) => c.startsWith("border-"));
const borderClasses = hasBorder
const defaultBorderClasses = hasBorder
? "" ? ""
: variant
? variantClasses[variant]
: "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400"; : "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400";
const hasSize = className const hasSize = className
@ -14,12 +40,19 @@ export function LoadingBorderSpinner({
.some( .some(
(c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"), (c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"),
); );
const sizeClasses = hasSize ? "" : "size-5";
const defaultSizeClasses = hasSize
? ""
: size
? sizeClasses[size]
: "size-5";
return ( return (
<span <span
className={`animate-spin rounded-full inline-block animate-fade-in ${borderClasses} ${sizeClasses} ${className}`.trim()}
role="status"
aria-label="در حال بارگذاری"
className={`inline-block animate-spin rounded-full shrink-0 ${defaultBorderClasses} ${defaultSizeClasses} ${className}`.trim()}
{...props} {...props}
/> />
); );
} }

5
src/components/Componentes/loading-icon-spinner.tsx

@ -6,12 +6,15 @@ export function LoadingIconSpinner({
}: ComponentProps<"svg">) { }: ComponentProps<"svg">) {
return ( return (
<svg <svg
className={`animate-spin transition-all duration-300 ease-out animate-fade-in ${className}`}
role="status"
aria-label="در حال بارگذاری"
className={`animate-spin transition-all duration-300 ease-out ${className}`}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<circle <circle
className="opacity-25" className="opacity-25"
cx="12" cx="12"

5
src/components/Componentes/loading-select-spinner.tsx

@ -5,7 +5,10 @@ export function LoadingSelectSpinner({
}) { }) {
return ( return (
<span <span
className={`size-3 animate-spin rounded-full inline-block border border-neutral-200 dark:border-neutral-800 border-t-rose-500 dark:border-t-rose-400 animate-fade-in ${className}`.trim()}
role="status"
aria-label="در حال بارگذاری"
className={`size-3 animate-spin rounded-full inline-block shrink-0 border border-neutral-200 dark:border-neutral-800 border-t-rose-500 dark:border-t-rose-400 ${className}`.trim()}
/> />
); );
} }

17
src/components/Componentes/question-answer-storage.tsx

@ -54,6 +54,7 @@ type QuestionAnswersContextValue = {
isLoading: boolean; isLoading: boolean;
setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void; setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void;
backendFields: MarriageField[]; backendFields: MarriageField[];
answers: QuestionAnswersByKey;
}; };
type QuestionAnswersProviderProps = { type QuestionAnswersProviderProps = {
@ -486,14 +487,12 @@ export function QuestionAnswersProvider({
questionsRef.current, questionsRef.current,
backendFieldsRef.current, backendFieldsRef.current,
); );
const nextDirtyKey = fullPayload.fields.find((field) =>
const pendingFields = fullPayload.fields.filter((field) =>
dirtyKeysRef.current.has(field.key), dirtyKeysRef.current.has(field.key),
)?.key;
);
const payload = { const payload = {
...fullPayload, ...fullPayload,
fields: nextDirtyKey
? fullPayload.fields.filter((field) => field.key === nextDirtyKey)
: [],
fields: pendingFields,
version: versionRef.current, version: versionRef.current,
}; };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
@ -538,6 +537,10 @@ export function QuestionAnswersProvider({
} }
} }
payload.fields.forEach((field) => {
dirtyKeysRef.current.delete(field.key);
});
// Do not mark a newer edit as synced just because an older request // Do not mark a newer edit as synced just because an older request
// completed. A forced exit waits for and saves that newer revision too. // completed. A forced exit waits for and saves that newer revision too.
if (revision !== answersRevisionRef.current) { if (revision !== answersRevisionRef.current) {
@ -547,8 +550,6 @@ export function QuestionAnswersProvider({
return; return;
} }
if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey);
if (dirtyKeysRef.current.size > 0) { if (dirtyKeysRef.current.size > 0) {
await flushAnswersRef.current(); await flushAnswersRef.current();
return; return;
@ -685,6 +686,7 @@ export function QuestionAnswersProvider({
isLoading: isLoadingData, isLoading: isLoadingData,
setAnswerValue, setAnswerValue,
backendFields: serverSectionData?.data || [], backendFields: serverSectionData?.data || [],
answers,
}), }),
[ [
flushAnswers, flushAnswers,
@ -694,6 +696,7 @@ export function QuestionAnswersProvider({
isLoadingData, isLoadingData,
setAnswerValue, setAnswerValue,
serverSectionData?.data, serverSectionData?.data,
answers,
], ],
); );

23
src/components/Componentes/question-card.tsx

@ -1,7 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { IoInformation } from "react-icons/io5"; import { IoInformation } from "react-icons/io5";
import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter";
import {
type QuestionCardIcon,
type QuestionListItem,
resolveSectionIcon,
} from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { UiIcon, type UiIconName } from "./ui-icon"; import { UiIcon, type UiIconName } from "./ui-icon";
@ -21,11 +25,19 @@ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
// no network request, present in the first rendered markup. // no network request, present in the first rendered markup.
const iconNameMap: Record<QuestionCardIcon, UiIconName> = { const iconNameMap: Record<QuestionCardIcon, UiIconName> = {
profile: "person", profile: "person",
contact: "contact",
health: "health",
education: "education", education: "education",
family: "family",
marriage: "marriage",
lifestyle: "lifestyle",
criteria: "criteria",
verification: "verification",
personality: "personality",
glasser: "glasser",
details: "details", details: "details",
checklist: "checklist", checklist: "checklist",
contact: "contact",
family_marital: "details",
family_marital: "marriage",
}; };
export function QuestionCard({ export function QuestionCard({
@ -41,7 +53,8 @@ export function QuestionCard({
? Math.max(0, Math.min(Math.round(progress), 100)) ? Math.max(0, Math.min(Math.round(progress), 100))
: 0; : 0;
const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE; const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE;
const iconName = iconNameMap[item.icon];
const resolvedIconKey = item.icon || resolveSectionIcon(item.slug);
const iconName = iconNameMap[resolvedIconKey] ?? "details";
const cardRef = useRef<HTMLElement>(null); const cardRef = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
@ -82,7 +95,6 @@ export function QuestionCard({
className="rounded-[20px] border border-white/80 bg-white px-3 py-3 shadow-[0_12px_28px_rgba(15,23,42,0.05)] transition-transform duration-200 hover:-translate-y-0.5" className="rounded-[20px] border border-white/80 bg-white px-3 py-3 shadow-[0_12px_28px_rgba(15,23,42,0.05)] transition-transform duration-200 hover:-translate-y-0.5"
> >
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<div className="rounded-[13px] bg-linear-to-br from-[#E03950]/15 to-[#E03950]/0 p-px shadow-[0_8px_18px_rgba(240,67,99,0.18)]">
<div className="relative flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-[12px] bg-linear-to-br from-[#E03950]/15 to-[#FE6F82]/15 text-white"> <div className="relative flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-[12px] bg-linear-to-br from-[#E03950]/15 to-[#FE6F82]/15 text-white">
<UiIcon <UiIcon
name={iconName} name={iconName}
@ -90,7 +102,6 @@ export function QuestionCard({
className="size-[22px]" className="size-[22px]"
/> />
</div> </div>
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="group-14 leading-tight font-bold text-[#1B1B1B] line-clamp-2"> <h2 className="group-14 leading-tight font-bold text-[#1B1B1B] line-clamp-2">

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

@ -0,0 +1,214 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionPhone, resetGeoPhoneStateForTesting } from "./question-phone";
let answerMap: Record<string, unknown> = {};
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val;
});
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
dictionary: { "Select country": "Select country" },
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null,
setAnswerValue: mockSetAnswerValue,
isLoading: false,
}),
}));
const phoneQuestion1: QuestionField = {
id: "contact.personal_contact_number",
title: "Personal Contact Number",
type: "phone",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "+44 7911 123456", range: [0, 0], options: [] },
options: [],
};
const phoneQuestion2: QuestionField = {
id: "contact.representative_contact_number",
title: "Representative's Contact Number",
type: "phone",
order: 2,
required: false,
baseRequired: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "+44 7911 123456", range: [0, 0], options: [] },
options: [],
};
describe("QuestionPhone IP country detection and shimmer", () => {
beforeEach(() => {
answerMap = {};
mockSetAnswerValue.mockClear();
localStorage.clear();
resetGeoPhoneStateForTesting();
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
it("renders shimmer on country button while IP request is pending, then shows resolved country code", async () => {
let resolveIpFetch!: (value: unknown) => void;
const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve;
});
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
ipPromise.then(
(data) =>
({
ok: true,
json: async () => data,
}) as unknown as Response,
),
);
const { container } = render(<QuestionPhone question={phoneQuestion1} />);
// While IP is pending, shimmer should be present inside the country button
const shimmerElements = container.querySelectorAll(".shimmer-bg");
expect(shimmerElements.length).toBeGreaterThan(0);
// The input itself should NOT have shimmer
const input = screen.getByRole("textbox");
expect(input.classList.contains("shimmer-bg")).toBe(false);
// Resolve IP fetch with Iran code
await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
});
await waitFor(() => {
// Shimmer elements should be gone
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
// Country code +98 should now be visible
expect(screen.getByText("+98")).toBeDefined();
expect(screen.getByText("🇮🇷")).toBeDefined();
});
});
it("shows default country code when IP request fails", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("Network failure"),
);
const { container } = render(
<QuestionPhone question={phoneQuestion1} countryCode="+44" />,
);
await waitFor(() => {
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
expect(screen.getByText("+44")).toBeDefined();
expect(screen.getByText("🇬🇧")).toBeDefined();
});
});
it("fetches IP country code only once when multiple fields are rendered and updates both", async () => {
let resolveIpFetch!: (value: unknown) => void;
const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve;
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() =>
ipPromise.then(
(data) =>
({
ok: true,
json: async () => data,
}) as unknown as Response,
),
);
render(
<>
<QuestionPhone question={phoneQuestion1} />
<QuestionPhone question={phoneQuestion2} />
</>,
);
// Only 1 fetch call should be triggered for both components
expect(fetchSpy).toHaveBeenCalledTimes(1);
await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
});
await waitFor(() => {
const irCodes = screen.getAllByText("+98");
expect(irCodes.length).toBe(2);
const flags = screen.getAllByText("🇮🇷");
expect(flags.length).toBe(2);
});
});
it("does not show shimmer and uses saved profile value if already present", async () => {
answerMap[phoneQuestion1.id] = {
countryCode: "1",
phoneNumber: "2025550143",
};
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { container } = render(<QuestionPhone question={phoneQuestion1} />);
// No shimmer because saved value is present
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
expect(screen.getByText("+1")).toBeDefined();
expect(screen.getByDisplayValue("2025550143")).toBeDefined();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("does not overwrite manual selection when user manually interacts", async () => {
let resolveIpFetch!: (value: unknown) => void;
const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve;
});
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
ipPromise.then(
(data) =>
({
ok: true,
json: async () => data,
}) as unknown as Response,
),
);
render(<QuestionPhone question={phoneQuestion1} />);
// User starts typing before IP request resolves
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "123456" } });
// Resolve IP fetch with Iran code
await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
});
// Should retain the manual input
expect(screen.getByDisplayValue("123456")).toBeDefined();
});
});

390
src/components/Componentes/question-phone.tsx

@ -25,6 +25,134 @@ type PhoneValueParts = {
const phoneUtil = PhoneNumberUtil.getInstance(); const phoneUtil = PhoneNumberUtil.getInstance();
// Module-level singleton state for IP phone country resolution
let cachedGeoCountryCode: string | null = null;
let geoIpPromise: Promise<string | null> | null = null;
const geoListeners = new Set<(code: string) => void>();
export function resetGeoPhoneStateForTesting() {
cachedGeoCountryCode = null;
geoIpPromise = null;
geoListeners.clear();
}
function getStoredGeoCode(): string | null {
if (cachedGeoCountryCode) return cachedGeoCountryCode;
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("geoIPPhoneCode");
if (stored) {
cachedGeoCountryCode = stored;
return stored;
}
} catch {}
}
return null;
}
export function setManuallySelectedGeoCode(code: string) {
cachedGeoCountryCode = code;
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", code);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
}
export function fetchGeoCountryCode(defaultCode = "+44"): Promise<string> {
const existing = getStoredGeoCode();
if (existing) {
return Promise.resolve(existing);
}
if (geoIpPromise) {
return geoIpPromise.then((res) => res || defaultCode);
}
geoIpPromise = (async () => {
try {
// 1. Primary: ipapi.co with 2s timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch("https://ipapi.co/json/", {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res?.ok) {
const data = await res.json();
if (data?.country_calling_code) {
const rawCode = String(data.country_calling_code).trim();
const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} catch {
clearTimeout(timeoutId);
}
// 2. Secondary fallback: ipwho.is with 2s timeout
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
2000,
);
try {
const res = await fetch("https://ipwho.is/", {
signal: secondaryController.signal,
});
clearTimeout(secondaryTimeoutId);
if (res?.ok) {
const data = await res.json();
if (data?.calling_code) {
const rawCode = String(data.calling_code).trim();
const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} catch {
clearTimeout(secondaryTimeoutId);
}
// 3. Fallback to default
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
cachedGeoCountryCode = defaultCode;
geoListeners.forEach((fn) => {
fn(defaultCode);
});
return defaultCode;
} catch {
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
cachedGeoCountryCode = defaultCode;
geoListeners.forEach((fn) => {
fn(defaultCode);
});
return defaultCode;
}
})();
return geoIpPromise.then((res) => res || defaultCode);
}
function isMarriagePhoneFieldValue( function isMarriagePhoneFieldValue(
value: unknown, value: unknown,
): value is MarriagePhoneFieldValue { ): value is MarriagePhoneFieldValue {
@ -230,15 +358,34 @@ export function QuestionPhone({
disabled, disabled,
}: QuestionPhoneProps) { }: QuestionPhoneProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question); const value = getAnswerValue(question);
const defaultCodeValue = countryCode.trim() || "+44"; const defaultCodeValue = countryCode.trim() || "+44";
const getCachedOrSavedCode = useCallback((): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("geoIPPhoneCode");
const userInteractedRef = useRef(false);
// Check if we already have a saved / existing value from profile / backend
const hasExplicitValue = useMemo(() => {
if (isMarriagePhoneFieldValue(value) && value.countryCode) {
return true;
}
if (typeof value === "string" && value.trim().length > 0) {
const parts = readPhoneValue(value, defaultCodeValue);
return Boolean(parts.codeValue && parts.codeValue !== defaultCodeValue);
}
return false;
}, [value, defaultCodeValue]);
const initialCachedCode = useMemo(() => {
return getStoredGeoCode();
}, []); }, []);
const [isResolvingCountry, setIsResolvingCountry] = useState(() => {
if (hasExplicitValue) return false;
if (initialCachedCode) return false;
return true;
});
const initialCode = useMemo(() => { const initialCode = useMemo(() => {
if (isMarriagePhoneFieldValue(value) && value.countryCode) { if (isMarriagePhoneFieldValue(value) && value.countryCode) {
return value.countryCode.startsWith("+") return value.countryCode.startsWith("+")
@ -251,10 +398,10 @@ export function QuestionPhone({
return parts.codeValue; return parts.codeValue;
} }
} }
const cached = getCachedOrSavedCode();
const cached = getStoredGeoCode();
if (cached) return cached; if (cached) return cached;
return defaultCodeValue; return defaultCodeValue;
}, [value, defaultCodeValue, getCachedOrSavedCode]);
}, [value, defaultCodeValue]);
const initialPhone = useMemo(() => { const initialPhone = useMemo(() => {
return readPhoneValue(value, defaultCodeValue).phoneValue; return readPhoneValue(value, defaultCodeValue).phoneValue;
@ -263,8 +410,6 @@ export function QuestionPhone({
const [codeValue, setCodeValue] = useState(initialCode); const [codeValue, setCodeValue] = useState(initialCode);
const [phoneValue, setPhoneValue] = useState(initialPhone); const [phoneValue, setPhoneValue] = useState(initialPhone);
const lastCommittedValueRef = useRef(value); const lastCommittedValueRef = useRef(value);
const hasFetchedIpRef = useRef(false);
const userInteractedRef = useRef(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
@ -281,10 +426,48 @@ export function QuestionPhone({
}, []); }, []);
const openSheet = useCallback(() => { const openSheet = useCallback(() => {
if (disabled) return;
if (disabled || isResolvingCountry) return;
setIsOpen(true); setIsOpen(true);
setIsClosing(false); setIsClosing(false);
}, [disabled]);
}, [disabled, isResolvingCountry]);
// IP Resolution Effect
useEffect(() => {
if (hasExplicitValue || !isResolvingCountry || userInteractedRef.current) {
return;
}
let isMounted = true;
const onGeoCodeResolved = (resolvedCode: string) => {
if (!isMounted || userInteractedRef.current) return;
setCodeValue(resolvedCode);
setIsResolvingCountry(false);
};
geoListeners.add(onGeoCodeResolved);
fetchGeoCountryCode(defaultCodeValue)
.then((resolvedCode) => {
if (!isMounted) return;
if (!userInteractedRef.current) {
setCodeValue(resolvedCode || defaultCodeValue);
}
setIsResolvingCountry(false);
})
.catch(() => {
if (!isMounted) return;
if (!userInteractedRef.current) {
setCodeValue(defaultCodeValue);
}
setIsResolvingCountry(false);
});
return () => {
isMounted = false;
geoListeners.delete(onGeoCodeResolved);
};
}, [hasExplicitValue, isResolvingCountry, defaultCodeValue]);
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue); const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState = const showInvalidState =
@ -341,6 +524,19 @@ export function QuestionPhone({
const activeFlag = useMemo(() => { const activeFlag = useMemo(() => {
if (!codeValue) return "🏳️"; if (!codeValue) return "🏳️";
const cleanActiveCode = codeValue.replace(/[^\d]/g, ""); const cleanActiveCode = codeValue.replace(/[^\d]/g, "");
if (cleanActiveCode === "44") {
const gb = countryList.find(
(c) => c.name.includes("United Kingdom") || c.name.includes("بریتانیا"),
);
if (gb) return gb.flag;
}
if (cleanActiveCode === "1") {
const us = countryList.find(
(c) =>
c.name.includes("United States") || c.name.includes("ایالات متحده"),
);
if (us) return us.flag;
}
const match = countryList.find( const match = countryList.find(
(c) => c.code.replace(/[^\d]/g, "") === cleanActiveCode, (c) => c.code.replace(/[^\d]/g, "") === cleanActiveCode,
); );
@ -349,6 +545,19 @@ export function QuestionPhone({
const selectedCountry = useMemo(() => { const selectedCountry = useMemo(() => {
const cleanActiveCode = codeValue.replace(/[^\d]/g, ""); const cleanActiveCode = codeValue.replace(/[^\d]/g, "");
if (cleanActiveCode === "44") {
const gb = countryList.find(
(c) => c.name.includes("United Kingdom") || c.name.includes("بریتانیا"),
);
if (gb) return gb;
}
if (cleanActiveCode === "1") {
const us = countryList.find(
(c) =>
c.name.includes("United States") || c.name.includes("ایالات متحده"),
);
if (us) return us;
}
return countryList.find( return countryList.find(
(country) => country.code.replace(/[^\d]/g, "") === cleanActiveCode, (country) => country.code.replace(/[^\d]/g, "") === cleanActiveCode,
); );
@ -382,130 +591,28 @@ export function QuestionPhone({
} }
const nextValue = readPhoneValue(value, defaultCodeValue); const nextValue = readPhoneValue(value, defaultCodeValue);
const cachedCode = getCachedOrSavedCode();
const cachedCode = getStoredGeoCode();
const resolvedCode =
(isMarriagePhoneFieldValue(value) && value.countryCode) ||
const explicit =
(isMarriagePhoneFieldValue(value) && Boolean(value.countryCode)) ||
(typeof value === "string" && (typeof value === "string" &&
value.trim().length > 0 && value.trim().length > 0 &&
nextValue.codeValue !== defaultCodeValue)
? nextValue.codeValue
: cachedCode || defaultCodeValue;
const maxLen = getMaxLengthForCountry(resolvedCode);
const truncatedPhone = nextValue.phoneValue.slice(0, maxLen);
nextValue.codeValue !== defaultCodeValue);
if (explicit) {
setIsResolvingCountry(false);
setCodeValue(nextValue.codeValue);
const maxLen = getMaxLengthForCountry(nextValue.codeValue);
setPhoneValue(nextValue.phoneValue.slice(0, maxLen));
} else if (value !== null && value !== undefined) {
const resolvedCode = cachedCode || defaultCodeValue;
setCodeValue(resolvedCode); setCodeValue(resolvedCode);
setPhoneValue(truncatedPhone);
lastCommittedValueRef.current = value;
}, [defaultCodeValue, value, getCachedOrSavedCode]);
// Non-blocking background IP resolution on first visit
useEffect(() => {
if (isLoading) return;
if (hasFetchedIpRef.current) return;
if (userInteractedRef.current) return;
// If user already has a saved value from backend, use it — no IP check needed
const hasSavedValue =
value &&
((isMarriagePhoneFieldValue(value) &&
(value.countryCode || value.phoneNumber)) ||
(typeof value === "string" && value.trim().length > 0));
if (hasSavedValue) {
hasFetchedIpRef.current = true;
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPPhone", "true");
if (isMarriagePhoneFieldValue(value) && value.countryCode) {
const code = value.countryCode.startsWith("+")
? value.countryCode
: `+${value.countryCode}`;
localStorage.setItem("geoIPPhoneCode", code);
setCodeValue(code);
}
}
return;
}
// Check if we already fetched IP in a previous session or determined code
if (typeof window !== "undefined") {
const cachedCode = localStorage.getItem("geoIPPhoneCode");
const alreadyChecked = localStorage.getItem("hasCheckedGeoIPPhone");
if (cachedCode || alreadyChecked === "true") {
hasFetchedIpRef.current = true;
if (cachedCode) {
setCodeValue(cachedCode);
}
return;
}
}
// First time ever — fetch country code from IP with strict 1.5s timeout
hasFetchedIpRef.current = true;
const applyCode = (code: string) => {
if (userInteractedRef.current) return;
const ipCode = code.startsWith("+") ? code : `+${code}`;
setCodeValue(ipCode);
if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", ipCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
};
const applyFallback = () => {
if (userInteractedRef.current) return;
const fallbackCode = "+44";
setCodeValue(fallbackCode);
if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", fallbackCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
const maxLen = getMaxLengthForCountry(resolvedCode);
setPhoneValue(nextValue.phoneValue.slice(0, maxLen));
} }
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
fetch("https://ipapi.co/json/", { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
clearTimeout(timeoutId);
if (data?.country_calling_code) {
applyCode(data.country_calling_code);
} else {
throw new Error("No calling code in response");
}
})
.catch(() => {
clearTimeout(timeoutId);
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
1500,
);
fetch("https://ipwho.is/", { signal: secondaryController.signal })
.then((res) => res.json())
.then((data) => {
clearTimeout(secondaryTimeoutId);
if (data?.calling_code) {
applyCode(data.calling_code);
} else {
applyFallback();
}
})
.catch(() => {
clearTimeout(secondaryTimeoutId);
applyFallback();
});
});
return () => {
clearTimeout(timeoutId);
};
}, [isLoading, value]);
lastCommittedValueRef.current = value;
}, [defaultCodeValue, value]);
const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => { const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => {
const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue); const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue);
@ -526,10 +633,8 @@ export function QuestionPhone({
const handleSelectCountryCode = (selectedCode: string) => { const handleSelectCountryCode = (selectedCode: string) => {
userInteractedRef.current = true; userInteractedRef.current = true;
if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", selectedCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
setIsResolvingCountry(false);
setManuallySelectedGeoCode(selectedCode);
const maxLen = getMaxLengthForCountry(selectedCode); const maxLen = getMaxLengthForCountry(selectedCode);
const truncatedPhone = phoneValue.slice(0, maxLen); const truncatedPhone = phoneValue.slice(0, maxLen);
@ -563,10 +668,21 @@ export function QuestionPhone({
<div className="flex shrink-0 items-center pl-2.5 pr-2"> <div className="flex shrink-0 items-center pl-2.5 pr-2">
<button <button
type="button" type="button"
disabled={disabled}
disabled={disabled || isResolvingCountry}
onClick={openSheet} onClick={openSheet}
className="flex items-center gap-1.5 h-full px-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer outline-none border-0 text-[15px] font-medium leading-none text-[#181818] tabular-nums" className="flex items-center gap-1.5 h-full px-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer outline-none border-0 text-[15px] font-medium leading-none text-[#181818] tabular-nums"
> >
{isResolvingCountry ? (
<div
className="flex items-center gap-1.5 py-1"
aria-hidden="true"
>
<span className="h-[18px] w-6 rounded-[4px] shimmer-bg inline-block shrink-0" />
<span className="h-[18px] w-8 rounded-[4px] shimmer-bg inline-block shrink-0" />
<span className="h-2.5 w-2.5 rounded-[2px] shimmer-bg inline-block shrink-0 opacity-60" />
</div>
) : (
<>
<span>{activeFlag}</span> <span>{activeFlag}</span>
<span>{codeValue || defaultCodeValue}</span> <span>{codeValue || defaultCodeValue}</span>
<svg <svg
@ -588,6 +704,8 @@ export function QuestionPhone({
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</>
)}
</button> </button>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" /> <span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" />
</div> </div>
@ -601,10 +719,8 @@ export function QuestionPhone({
maxLength={getMaxLengthForCountry(codeValue)} maxLength={getMaxLengthForCountry(codeValue)}
onChange={(event) => { onChange={(event) => {
userInteractedRef.current = true; userInteractedRef.current = true;
if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", codeValue);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
setIsResolvingCountry(false);
setManuallySelectedGeoCode(codeValue);
const nextPhoneValue = sanitizePhoneNumber(event.target.value); const nextPhoneValue = sanitizePhoneNumber(event.target.value);
const maxLen = getMaxLengthForCountry(codeValue); const maxLen = getMaxLengthForCountry(codeValue);
const truncatedPhone = nextPhoneValue.slice(0, maxLen); const truncatedPhone = nextPhoneValue.slice(0, maxLen);

70
src/components/Componentes/question-sheet.tsx

@ -1,7 +1,8 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { getLanguageList, LANGUAGES_EN, LANGUAGES_FA } from "@/data/languages";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { Button } from "./button"; import { Button } from "./button";
@ -71,7 +72,72 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]); }, [isOpen, closeSheet]);
const options = question.options || [];
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 options = useMemo(() => {
const rawOptions = question.options || [];
if (!isLanguageQuestion) {
return rawOptions;
}
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"
? 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);
mergedOptions.push({
id: finalId,
value: existing?.value ?? cleanSlug,
label: existing?.label ?? localizedLabel,
order: existing?.order ?? idx,
});
});
rawOptions.forEach((opt) => {
if (!seenIds.has(opt.id)) {
seenIds.add(opt.id);
mergedOptions.push(opt);
}
});
return mergedOptions;
}, [question, isLanguageQuestion, locale, t]);
const COMPACT_OPTIONS_MAX = 6; const COMPACT_OPTIONS_MAX = 6;

138
src/components/Componentes/question-slider.test.tsx

@ -0,0 +1,138 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { mapBackendQuestionToFrontend } from "@/lib/schema-adapter";
import { QuestionSlider } from "./question-slider";
let answerValue: any = null;
const setAnswerValueMock = vi.fn();
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
dictionary: { From: "From", To: "To" },
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: () => answerValue,
setAnswerValue: setAnswerValueMock,
}),
}));
describe("QuestionSlider", () => {
beforeEach(() => {
answerValue = null;
setAnswerValueMock.mockClear();
});
afterEach(() => {
cleanup();
});
it("extracts range correctly from backend validation schema", () => {
const backendQuestion = {
id: "appearance_health_activity.height_in_centimeters",
title: "Height in Centimeters",
type: "scale",
order: 1,
required: true,
is_required: true,
show_guardian_notice: false,
validation: { min: 100, max: 230 },
ui_config: { placeholder_en: "175", private: true },
logic: null,
is_visible: true,
options: [],
description: "",
tooltip: "",
placeholder: "175",
};
const frontendQ = mapBackendQuestionToFrontend(backendQuestion as any, 0);
expect(frontendQ.extras.range).toEqual([100, 230]);
});
it("renders scale marks correctly across the range", () => {
const heightQuestion = {
id: "appearance_health_activity.height_in_centimeters",
title: "Height in Centimeters",
type: "scale",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "175", range: [100, 230], options: [] },
options: [],
validation: { min: 100, max: 230 },
} as QuestionField;
answerValue = 178;
render(<QuestionSlider question={heightQuestion} />);
const slider = screen.getByRole("slider") as HTMLInputElement;
expect(slider.min).toBe("100");
expect(slider.max).toBe("230");
expect(slider.value).toBe("178");
// The value badge shows 178
expect(screen.getByText("178")).toBeDefined();
// Scale marks should be rendered with multiple ticks (100, 120, ..., 230)
expect(screen.getByText("100")).toBeDefined();
expect(screen.getByText("230")).toBeDefined();
expect(screen.queryByText("0")).toBeNull();
});
it("updates answer value when slider changes", () => {
const weightQuestion = {
id: "appearance_health_activity.weight_in_kilograms",
title: "Weight in Kilograms",
type: "scale",
order: 2,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "70", range: [40, 180], options: [] },
options: [],
validation: { min: 40, max: 180 },
} as QuestionField;
render(<QuestionSlider question={weightQuestion} />);
const slider = screen.getByRole("slider");
fireEvent.change(slider, { target: { value: "85" } });
expect(setAnswerValueMock).toHaveBeenCalledWith(weightQuestion, 85);
});
it("falls back gracefully when range is not in extras but in validation", () => {
const customQuestion = {
id: "custom_scale",
title: "Satisfaction",
type: "scale",
order: 3,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "5", range: [0, 0], options: [] },
options: [],
validation: { min: 1, max: 10 },
} as QuestionField;
render(<QuestionSlider question={customQuestion} />);
const slider = screen.getByRole("slider") as HTMLInputElement;
expect(slider.min).toBe("1");
expect(slider.max).toBe("10");
expect(screen.getByText("1")).toBeDefined();
expect(screen.getByText("10")).toBeDefined();
});
});

214
src/components/Componentes/question-slider.tsx

@ -1,6 +1,5 @@
"use client"; "use client";
import { useLayoutEffect, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
@ -16,26 +15,107 @@ export function QuestionSlider({
disabled, disabled,
}: QuestionSliderProps) { }: QuestionSliderProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [min, max] = question.extras.range;
const initialValue = Math.round((min + max) / 2);
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question); const storedValue = getAnswerValue(question);
const [min, max] = (() => {
const rawMin =
(question.extras?.range && question.extras.range[0] !== question.extras.range[1]
? question.extras.range[0]
: undefined) ??
question.validation?.min ??
question.validation?.range?.[0] ??
question.ui_config?.range?.[0] ??
question.ui_config?.min ??
(question.extras?.range?.[0] !== undefined &&
question.extras?.range?.[1] !== undefined &&
question.extras.range[0] < question.extras.range[1]
? question.extras.range[0]
: undefined);
const rawMax =
(question.extras?.range && question.extras.range[0] !== question.extras.range[1]
? question.extras.range[1]
: undefined) ??
question.validation?.max ??
question.validation?.range?.[1] ??
question.ui_config?.range?.[1] ??
question.ui_config?.max ??
(question.extras?.range?.[0] !== undefined &&
question.extras?.range?.[1] !== undefined &&
question.extras.range[0] < question.extras.range[1]
? question.extras.range[1]
: undefined);
const nMin = typeof rawMin === "number" ? rawMin : Number(rawMin);
const nMax = typeof rawMax === "number" ? rawMax : Number(rawMax);
if (!Number.isNaN(nMin) && !Number.isNaN(nMax) && nMax > nMin) {
return [nMin, nMax];
}
if (question.options && question.options.length > 1) {
const optNums = question.options
.map((o) => Number(o.value))
.filter((v) => !Number.isNaN(v));
if (optNums.length > 1) {
const minOpt = Math.min(...optNums);
const maxOpt = Math.max(...optNums);
if (maxOpt > minOpt) {
return [minOpt, maxOpt];
}
}
}
const qId = (question.id || "").toLowerCase();
if (qId.includes("height")) return [100, 230];
if (qId.includes("weight")) return [40, 180];
if (qId.includes("age")) return [18, 80];
return [0, 100];
})();
const step =
Number(question.validation?.step ?? question.ui_config?.step ?? 1) || 1;
const initialValue =
typeof question.extras?.placeHolder === "string" &&
!Number.isNaN(Number(question.extras.placeHolder)) &&
Number(question.extras.placeHolder) >= min &&
Number(question.extras.placeHolder) <= max
? Number(question.extras.placeHolder)
: Math.round((min + max) / 2);
const isDesiredAgeRange = false; const isDesiredAgeRange = false;
const thumbWidth = 18; const thumbWidth = 18;
const bubbleHalfWidth = 18;
const steps = (() => { const steps = (() => {
const range = max - min; const range = max - min;
const interval = range <= 15 ? 1 : range <= 50 ? 5 : range <= 150 ? 10 : 20;
if (range <= 0) return [min];
let interval = 1;
if (range <= 15) {
interval = 1;
} else if (range <= 30) {
interval = 5;
} else if (range <= 70) {
interval = 10;
} else if (range <= 150) {
interval = 20;
} else if (range <= 300) {
interval = 50;
} else {
interval = 100;
}
const arr: number[] = []; const arr: number[] = [];
for (let val = min; val <= max; val += interval) { for (let val = min; val <= max; val += interval) {
arr.push(val); arr.push(val);
} }
if (arr[arr.length - 1] !== max) { if (arr[arr.length - 1] !== max) {
const lastVal = arr[arr.length - 1]; const lastVal = arr[arr.length - 1];
if (max - lastVal < interval * 0.5) {
if (max - lastVal < interval * 0.6) {
arr.pop(); arr.pop();
} }
arr.push(max); arr.push(max);
@ -68,47 +148,23 @@ export function QuestionSlider({
} }
} }
// Single-slider states (only used if isDesiredAgeRange is false)
const value = typeof storedValue === "number" ? storedValue : initialValue;
const progress = max === min ? 0 : ((value - min) / (max - min)) * 100;
const sliderWrapperRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLInputElement>(null);
const [bubblePosition, setBubblePosition] = useState(bubbleHalfWidth);
useLayoutEffect(() => {
if (isDesiredAgeRange) return;
const wrapper = sliderWrapperRef.current;
const slider = sliderRef.current;
if (!wrapper || !slider) {
return;
}
const updateBubblePosition = () => {
const wrapperRect = wrapper.getBoundingClientRect();
const sliderRect = slider.getBoundingClientRect();
const sliderLeft = sliderRect.left - wrapperRect.left;
const thumbCenter =
sliderLeft +
thumbWidth / 2 +
(progress / 100) * (sliderRect.width - thumbWidth);
// Single-slider states
const parsedStoredValue =
typeof storedValue === "number"
? storedValue
: typeof storedValue === "string" &&
storedValue.trim() !== "" &&
!Number.isNaN(Number(storedValue))
? Number(storedValue)
: null;
setBubblePosition(
Math.min(
Math.max(thumbCenter, bubbleHalfWidth),
wrapperRect.width - bubbleHalfWidth,
),
);
};
const value =
parsedStoredValue !== null
? Math.max(min, Math.min(max, parsedStoredValue))
: initialValue;
updateBubblePosition();
const resizeObserver = new ResizeObserver(updateBubblePosition);
resizeObserver.observe(wrapper);
resizeObserver.observe(slider);
return () => resizeObserver.disconnect();
}, [progress]);
const progress =
max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
if (isDesiredAgeRange) { if (isDesiredAgeRange) {
const handleFromChange = (newFrom: number) => { const handleFromChange = (newFrom: number) => {
@ -135,8 +191,8 @@ export function QuestionSlider({
<QuestionTitle question={question} /> <QuestionTitle question={question} />
{/* First Slider (From Age) */} {/* First Slider (From Age) */}
<div className="flex flex-col gap-2 pt-2">
<div className="flex justify-between items-center text-[14px] font-bold text-[#181818]">
<div className="flex flex-col gap-2 pt-2" dir="ltr">
<div className="flex justify-between items-center text-[14px] font-bold text-[#181818]" dir="auto">
<span>{t.From ?? "From"}</span> <span>{t.From ?? "From"}</span>
<span className="rounded-[8px] bg-[#FCE7EA] text-[#F2465F] px-2.5 py-1 text-[13px] font-bold"> <span className="rounded-[8px] bg-[#FCE7EA] text-[#F2465F] px-2.5 py-1 text-[13px] font-bold">
{fromVal} {fromVal}
@ -147,7 +203,7 @@ export function QuestionSlider({
type="range" type="range"
min={min} min={min}
max={max} max={max}
step={1}
step={step}
value={fromVal} value={fromVal}
onChange={(e) => handleFromChange(Number(e.target.value))} onChange={(e) => handleFromChange(Number(e.target.value))}
disabled={disabled} disabled={disabled}
@ -157,18 +213,18 @@ export function QuestionSlider({
}} }}
/> />
</div> </div>
<div className="relative h-4 text-[#B7B7B7] text-[10px] font-medium w-full">
{steps.map((step) => {
const leftPct = ((step - min) / (max - min)) * 100;
<div className="relative h-4 text-[#B7B7B7] text-[10px] font-medium w-full select-none">
{steps.map((s) => {
const leftPct = ((s - min) / (max - min)) * 100;
return ( return (
<span <span
key={`from-${step}`}
className="absolute -translate-x-1/2"
key={`from-${s}`}
className="absolute -translate-x-1/2 select-none"
style={{ style={{
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`,
}} }}
> >
{step}
{s}
</span> </span>
); );
})} })}
@ -176,8 +232,8 @@ export function QuestionSlider({
</div> </div>
{/* Second Slider (To Age) */} {/* Second Slider (To Age) */}
<div className="flex flex-col gap-2 pt-2">
<div className="flex justify-between items-center text-[14px] font-bold text-[#181818]">
<div className="flex flex-col gap-2 pt-2" dir="ltr">
<div className="flex justify-between items-center text-[14px] font-bold text-[#181818]" dir="auto">
<span>{t.To ?? "To"}</span> <span>{t.To ?? "To"}</span>
<span className="rounded-[8px] bg-[#FCE7EA] text-[#F2465F] px-2.5 py-1 text-[13px] font-bold"> <span className="rounded-[8px] bg-[#FCE7EA] text-[#F2465F] px-2.5 py-1 text-[13px] font-bold">
{toVal} {toVal}
@ -188,7 +244,7 @@ export function QuestionSlider({
type="range" type="range"
min={min} min={min}
max={max} max={max}
step={1}
step={step}
value={toVal} value={toVal}
onChange={(e) => handleToChange(Number(e.target.value))} onChange={(e) => handleToChange(Number(e.target.value))}
disabled={disabled} disabled={disabled}
@ -198,18 +254,18 @@ export function QuestionSlider({
}} }}
/> />
</div> </div>
<div className="relative h-4 text-[#B7B7B7] text-[10px] font-medium w-full">
{steps.map((step) => {
const leftPct = ((step - min) / (max - min)) * 100;
<div className="relative h-4 text-[#B7B7B7] text-[10px] font-medium w-full select-none">
{steps.map((s) => {
const leftPct = ((s - min) / (max - min)) * 100;
return ( return (
<span <span
key={`to-${step}`}
className="absolute -translate-x-1/2"
key={`to-${s}`}
className="absolute -translate-x-1/2 select-none"
style={{ style={{
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`,
}} }}
> >
{step}
{s}
</span> </span>
); );
})} })}
@ -227,25 +283,24 @@ export function QuestionSlider({
].join(" ")} ].join(" ")}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<div className="pt-7">
<div ref={sliderWrapperRef} className="relative">
<div className="pt-7" dir="ltr">
<div className="relative">
<div <div
className="-translate-x-1/2 absolute -top-7.5 z-10 flex items-center justify-center rounded-[8px] bg-[#F2465F] px-3 py-1 text-[14px] font-bold text-white shadow-md after:absolute after:-bottom-[4px] after:left-1/2 after:h-[9px] after:w-2.5 after:-translate-x-1/2 after:rotate-45 after:rounded-[2px] after:bg-[#F2465F] after:content-['']"
style={{ left: `${bubblePosition}px` }}
className="-translate-x-1/2 absolute -top-7.5 z-10 flex items-center justify-center rounded-[8px] bg-[#F2465F] px-3 py-1 text-[14px] font-bold text-white shadow-md pointer-events-none select-none transition-all duration-75 after:absolute after:-bottom-[4px] after:left-1/2 after:h-[9px] after:w-2.5 after:-translate-x-1/2 after:rotate-45 after:rounded-[2px] after:bg-[#F2465F] after:content-['']"
style={{
left: `calc(${progress}% - (${progress / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`,
}}
> >
{value} {value}
</div> </div>
<input <input
ref={sliderRef}
type="range" type="range"
min={min} min={min}
max={max} max={max}
step={1}
step={step}
value={value} value={value}
onChange={(event) => onChange={(event) =>
setAnswerValue(
question, Number(event.target.value),
)
setAnswerValue(question, Number(event.target.value))
} }
disabled={disabled} disabled={disabled}
className="question-slider-range w-full" className="question-slider-range w-full"
@ -254,21 +309,18 @@ export function QuestionSlider({
}} }}
/> />
</div> </div>
<div
className="relative mt-2 h-5 text-[#B7B7B7] text-[10px] font-medium"
style={{ width: "100%" }}
>
{steps.map((step) => {
const leftPct = ((step - min) / (max - min)) * 100;
<div className="relative mt-2 h-5 text-[#B7B7B7] text-[10px] font-medium select-none w-full">
{steps.map((stepVal) => {
const leftPct = ((stepVal - min) / (max - min)) * 100;
return ( return (
<span <span
key={step}
className="absolute -translate-x-1/2"
key={stepVal}
className="absolute -translate-x-1/2 select-none"
style={{ style={{
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`,
}} }}
> >
{step}
{stepVal}
</span> </span>
); );
})} })}

134
src/components/Componentes/schema-question-flow.integration.test.tsx

@ -336,4 +336,138 @@ describe("Schema Question Flow Integration", () => {
// Static text should not exist // Static text should not exist
expect(screen.queryByText("First Q")).toBeNull(); expect(screen.queryByText("First Q")).toBeNull();
}); });
it("should dynamically show conditional child question when parent option is clicked, and hide when changed", async () => {
const dynamicSchema = {
form_id: "profile",
answers: {},
sections: [
{
id: "sec_health",
title: "Health",
order: 1,
cards: [
{
id: "card_health",
order: 1,
questions: [
{
id: "appearance_health.physical_health_status",
title: "Physical Health Status",
type: "radio",
order: 1,
required: true,
is_visible: true,
options: [
{
id: "appearance_health.physical_health_status.i_am_in_perfect_health",
value: "perfect",
label: "Perfect Health",
order: 1,
},
{
id: "appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
value: "chronic",
label: "Chronic Illness",
order: 2,
},
],
},
{
id: "appearance_health.physical_health_description",
title: "Physical Health Description",
type: "text",
order: 2,
required: true,
is_visible: false,
visibility: {
parent_question_id:
"appearance_health.physical_health_status",
trigger_option_ids: [
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
],
operator: "any_of",
},
options: [],
},
],
},
],
},
],
progress: { sections_progress: {} },
};
(useMarriageSectionDataQuery as any).mockReturnValue({
data: { slug: "sec_health", data: [] },
isLoading: false,
});
(useFormOverviewQuery as any).mockReturnValue({
data: {
...dynamicSchema,
sections: dynamicSchema.sections.map((s) => ({
...s,
kind: "profile",
progress: { current_step: 0, total_steps: 1, completion_percent: 0 },
})),
},
isLoading: false,
isFetching: false,
});
(useFormSectionQuery as any).mockReturnValue({
data: {
section: dynamicSchema.sections[0],
answers: {},
progress: {},
section_progress: {
current_step: 0,
total_steps: 1,
completion_percent: 0,
},
},
isLoading: false,
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
itemSlug="sec_health"
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>,
);
// Initially child question is NOT in DOM
await waitFor(() => {
expect(screen.getByLabelText("Perfect Health")).toBeDefined();
});
expect(screen.queryByText("Description")).toBeNull();
// Click Chronic Illness
const chronicOption = screen.getByLabelText("Chronic Illness");
fireEvent.click(chronicOption);
// Child question appears immediately!
await waitFor(() => {
expect(screen.getByText("Description")).toBeDefined();
});
// Click Perfect Health
const perfectOption = screen.getByLabelText("Perfect Health");
fireEvent.click(perfectOption);
// Child question disappears immediately!
await waitFor(() => {
expect(screen.queryByText("Description")).toBeNull();
});
});
}); });

39
src/components/Componentes/slider-slide-two.test.tsx

@ -0,0 +1,39 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { describe, expect, it, afterEach, vi } from "vitest";
import { SliderSlideTwo } from "./slider-slide-two";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/hooks/marriage/use-marriage-config", () => ({
useMarriageConfigQuery: () => ({
data: {
intro_video_url: "https://example.com/video.mp4",
intro_video_thumbnail_url: "/assets/images/Frame 2095586523.png",
video_step_2_url: "https://example.com/video2.mp4",
video_step_2_thumbnail_url: "/assets/images/Frame 20953586523.png",
},
}),
}));
describe("SliderSlideTwo", () => {
afterEach(() => {
cleanup();
});
it("renders video card with portrait thumbnail and play button", () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<SliderSlideTwo index={1} />
</QueryClientProvider>,
);
const videoImg = screen.getByAltText("video");
expect(videoImg).toBeDefined();
expect(videoImg.getAttribute("src")).toContain("Frame%2020953586523.png");
const playBtn = screen.getByAltText("play");
expect(playBtn).toBeDefined();
expect(screen.getByText("Dr. Hasti Masoudi")).toBeDefined();
});
});

31
src/components/Componentes/slider-slide-two.tsx

@ -12,6 +12,13 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { data: config } = useMarriageConfigQuery(); const { data: config } = useMarriageConfigQuery();
const thumbnailSrc =
config?.video_step_2_thumbnail_url ||
(config?.intro_video_thumbnail_url &&
!config.intro_video_thumbnail_url.includes("Frame 2095586523")
? config.intro_video_thumbnail_url
: undefined);
return ( return (
<section <section
aria-label={`Slide ${index + 1}`} aria-label={`Slide ${index + 1}`}
@ -20,32 +27,33 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
<div className="min-h-0 flex-1 overflow-y-auto pb-[86px]"> <div className="min-h-0 flex-1 overflow-y-auto pb-[86px]">
<SliderHeader index={index} title="Watch Video" /> <SliderHeader index={index} title="Watch Video" />
<div <div
className="mt-8 flex min-h-0 justify-center cursor-pointer group"
className="mt-6 flex min-h-0 justify-center cursor-pointer group"
onClick={() => setIsPlayerOpen(true)} onClick={() => setIsPlayerOpen(true)}
> >
<div className="relative w-full max-w-[344px] overflow-hidden rounded-2xl">
<div className="relative w-full max-w-[344px] aspect-[344/488] overflow-hidden rounded-[24px] shadow-xs">
<NetworkImage <NetworkImage
src={config?.intro_video_thumbnail_url}
src={thumbnailSrc}
fallbackSrc="/assets/images/Frame 20953586523.png" fallbackSrc="/assets/images/Frame 20953586523.png"
alt="video" alt="video"
width={344}
height={488}
className="h-auto w-full transition-transform duration-300 group-hover:scale-102 rounded-2xl"
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
priority
/> />
<div className="absolute inset-0 bg-black/5 transition-colors duration-300 group-hover:bg-black/15" />
<Image <Image
src={"/assets/images/Frame 1116607280.svg"} src={"/assets/images/Frame 1116607280.svg"}
alt="play" alt="play"
width={68} width={68}
height={68} height={68}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95 z-10"
/> />
</div> </div>
</div> </div>
<div className="mt-5">
<div className="flex items-center justify-center gap-1">
<div className="mt-4">
<div className="flex items-center justify-center gap-1.5">
<Image <Image
src={"/assets/images/Avatar Image.png"} src={"/assets/images/Avatar Image.png"}
alt="video"
alt="Dr. Hasti Masoudi"
width={20} width={20}
height={20} height={20}
className="rounded-full" className="rounded-full"
@ -54,12 +62,13 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
Dr. Hasti Masoudi Dr. Hasti Masoudi
</p> </p>
</div> </div>
<p className="group-12 text-[#4D4D4D] text-center mt-2.5">
<p className="group-12 text-[#4D4D4D] text-center mt-2 max-w-[340px] mx-auto leading-relaxed">
We have come together with the goal of creating a secure and We have come together with the goal of creating a secure and
confidential path for "permanent marriage" among Muslims confidential path for "permanent marriage" among Muslims
</p> </p>
</div> </div>
</div> </div>
<VideoPlayer <VideoPlayer
isOpen={isPlayerOpen} isOpen={isPlayerOpen}
onClose={() => setIsPlayerOpen(false)} onClose={() => setIsPlayerOpen(false)}

200
src/components/Componentes/ui-icon.tsx

@ -23,6 +23,14 @@ export type UiIconName =
| "details" | "details"
| "checklist" | "checklist"
| "contact" | "contact"
| "health"
| "family"
| "marriage"
| "lifestyle"
| "criteria"
| "verification"
| "personality"
| "glasser"
| "success" | "success"
| "diamond"; | "diamond";
@ -254,6 +262,198 @@ export function UiIcon({ name, ...props }: UiIconProps) {
</svg> </svg>
); );
case "health":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 21.35C11.66 21.35 11.33 21.22 11.07 20.97L4.08 14.34C2.15 12.49 1 9.87 1 7.11C1 3.74 3.74 1 7.11 1C8.98 1 10.74 1.83 12 3.25C13.26 1.83 15.02 1 16.89 1C20.26 1 23 3.74 23 7.11C23 9.87 21.85 12.49 19.92 14.34L12.93 20.97C12.67 21.22 12.34 21.35 12 21.35ZM11.1 7.5H8C7.45 7.5 7 7.95 7 8.5C7 9.05 7.45 9.5 8 9.5H10.15L11.14 12.18C11.27 12.54 11.61 12.78 12 12.78C12.39 12.78 12.73 12.54 12.86 12.18L13.84 9.5H16C16.55 9.5 17 9.05 17 8.5C17 7.95 16.55 7.5 16 7.5H14.85L13.86 4.82C13.73 4.46 13.39 4.22 13 4.22C12.61 4.22 12.27 4.46 12.14 4.82L11.1 7.5Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="22"
y1="21.35"
x2="2"
y2="1"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "family":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
d="M8.5 6C10.16 6 11.5 4.66 11.5 3C11.5 1.34 10.16 0 8.5 0C6.84 0 5.5 1.34 5.5 3C5.5 4.66 6.84 6 8.5 6ZM16.5 7C17.88 7 19 5.88 19 4.5C19 3.12 17.88 2 16.5 2C15.12 2 14 3.12 14 4.5C14 5.88 15.12 7 16.5 7ZM8.5 8C5.83 8 0.5 9.34 0.5 12V14C0.5 14.55 0.95 15 1.5 15H15.5C16.05 15 16.5 14.55 16.5 14V12C16.5 9.34 11.17 8 8.5 8ZM16.5 9C16.03 9 15.48 9.05 14.88 9.15C16.14 10.05 17 11.23 17 12.67V15H22.5C23.05 15 23.5 14.55 23.5 14V12C23.5 9.78 19.33 9 16.5 9ZM12 17.5C13.38 17.5 14.5 16.38 14.5 15C14.5 13.62 13.38 12.5 12 12.5C10.62 12.5 9.5 13.62 9.5 15C9.5 16.38 10.62 17.5 12 17.5ZM12 18.5C9.67 18.5 5 19.67 5 22V23C5 23.55 5.45 24 6 24H18C18.55 24 19 23.55 19 23V22C19 19.67 14.33 18.5 12 18.5Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="23.5"
y1="24"
x2="0.5"
y2="0"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "marriage":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 3C4.69 3 2 5.69 2 9C2 12.31 4.69 15 8 15C9.09 15 10.11 14.71 11 14.21C11.89 14.71 12.91 15 14 15C17.31 15 20 12.31 20 9C20 5.69 17.31 3 14 3C12.91 3 11.89 3.29 11 3.79C10.11 3.29 9.09 3 8 3ZM8 5C6.18 5 4.59 6.22 4.14 7.97C5.16 7.37 6.43 7 8 7C9.57 7 10.84 7.37 11.86 7.97C11.41 6.22 9.82 5 8 5ZM14 5C12.18 5 10.59 6.22 10.14 7.97C11.16 7.37 12.43 7 14 7C15.57 7 16.84 7.37 17.86 7.97C17.41 6.22 15.82 5 14 5ZM8 9C6.9 9 6 9.9 6 11C6 12.1 6.9 13 8 13C9.1 13 10 12.1 10 11C10 9.9 9.1 9 8 9ZM14 9C12.9 9 12 9.9 12 11C12 12.1 12.9 13 14 13C15.1 13 16 12.1 16 11C16 9.9 15.1 9 14 9ZM11 16.5C11 16.22 11.22 16 11.5 16H12.5C12.78 16 13 16.22 13 16.5V20.5C13 20.78 12.78 21 12.5 21H11.5C11.22 21 11 20.78 11 20.5V16.5ZM7.5 17C7.5 16.72 7.72 16.5 8 16.5H9C9.28 16.5 9.5 16.72 9.5 17V19.5C9.5 19.78 9.28 20 9 20H8C7.72 20 7.5 19.78 7.5 19.5V17ZM14.5 17C14.5 16.72 14.72 16.5 15 16.5H16C16.28 16.5 16.5 16.72 16.5 17V19.5C16.5 19.78 16.28 20 16 20H15C14.72 20 14.5 19.78 14.5 19.5V17Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="20"
y1="21"
x2="2"
y2="3"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "lifestyle":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4ZM14.12 7.88L8.88 9.88L6.88 15.12L12.12 13.12L14.12 7.88ZM11.29 11.29C11.1 11.48 11 11.73 11 12C11 12.55 11.45 13 12 13C12.27 13 12.52 12.9 12.71 12.71C12.9 12.52 13 12.27 13 12C13 11.45 12.55 11 12 11C11.73 11 11.48 11.1 11.29 11.29Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="22"
y1="22"
x2="2"
y2="2"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "criteria":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 2C5.58 2 2 5.58 2 10C2 12.08 2.8 13.98 4.11 15.4L2.29 17.22C1.9 17.61 1.9 18.24 2.29 18.63C2.68 19.02 3.31 19.02 3.7 18.63L5.52 16.81C6.82 17.57 8.35 18 10 18C14.42 18 18 14.42 18 10C18 5.58 14.42 2 10 2ZM4 10C4 6.69 6.69 4 10 4C13.31 4 16 6.69 16 10C16 13.31 13.31 16 10 16C6.69 16 4 13.31 4 10ZM10 6.5C8.9 6.5 8 7.4 8 8.5C8 9.6 10 12 10 12C10 12 12 9.6 12 8.5C12 7.4 11.1 6.5 10 6.5ZM19.7 18.29L22.71 21.3C23.1 21.69 23.1 22.32 22.71 22.71C22.32 23.1 21.69 23.1 21.3 22.71L18.29 19.7C18.8 19.28 19.28 18.8 19.7 18.29Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="23"
y1="23"
x2="2"
y2="2"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "verification":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1ZM10.2 16.6L6.4 12.8L7.8 11.4L10.2 13.8L16.2 7.8L17.6 9.2L10.2 16.6Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="21"
y1="23"
x2="3"
y2="1"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "personality":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
{/* Modern Solid Psychological Mind & Brain Cortex */}
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11 3.5C9.2 3.5 7.8 4.5 7.2 5.5C6 5.2 4.8 5.8 4.2 7C3.2 8.8 3.5 11 4.2 12.2C3.5 13.5 3.5 15.2 4.2 16.5C4.2 18 5.2 19.5 6.8 20.2C8.2 20.8 9.8 20.2 11 19.2V3.5ZM13 3.5V19.2C14.2 20.2 15.8 20.8 17.2 20.2C18.8 19.5 19.8 18 19.8 16.5C20.5 15.2 20.5 13.5 19.8 12.2C20.5 11 20.8 8.8 19.8 7C19.2 5.8 18 5.2 16.8 5.5C16.2 4.5 14.8 3.5 13 3.5ZM7.5 8C7.5 7.45 7.95 7 8.5 7C9.05 7 9.5 7.45 9.5 8C9.5 8.55 9.05 9 8.5 9C7.95 9 7.5 8.55 7.5 8ZM14.5 8C14.5 7.45 14.95 7 15.5 7C16.05 7 16.5 7.45 16.5 8C16.5 8.55 16.05 9 15.5 9C14.95 9 14.5 8.55 14.5 8ZM8.5 14.5C7.95 14.5 7.5 14.95 7.5 15.5C7.5 16.05 7.95 16.5 8.5 16.5C9.05 16.5 9.5 16.05 9.5 15.5C9.5 14.95 9.05 14.5 8.5 14.5ZM15.5 14.5C14.95 14.5 14.5 14.95 14.5 15.5C14.5 16.05 14.95 16.5 15.5 16.5C16.05 16.5 16.5 16.05 16.5 15.5C16.5 14.95 16.05 14.5 15.5 14.5ZM6.5 11.75C6.5 11.34 6.84 11 7.25 11H9.25C9.66 11 10 11.34 10 11.75C10 12.16 9.66 12.5 9.25 12.5H7.25C6.84 12.5 6.5 12.16 6.5 11.75ZM14 11.75C14 11.34 14.34 11 14.75 11H16.75C17.16 11 17.5 11.34 17.5 11.75C17.5 12.16 17.16 12.5 16.75 12.5H14.75C14.34 12.5 14 12.16 14 11.75Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="20.8"
y1="20.8"
x2="3.2"
y2="3.5"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "glasser":
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
{/* 5-Point Psychological Needs & Mental Health Wellness Star Badge */}
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C12.35 2 12.68 2.2 12.84 2.52L15.35 7.61C15.5 7.9 15.78 8.1 16.1 8.15L21.72 8.97C22.08 9.02 22.38 9.27 22.49 9.61C22.6 9.95 22.5 10.33 22.24 10.58L18.17 14.55C17.94 14.78 17.83 15.11 17.89 15.43L18.85 21.03C18.91 21.39 18.76 21.75 18.46 21.97C18.16 22.19 17.76 22.22 17.43 22.05L12.4 19.41C12.15 19.28 11.85 19.28 11.6 19.41L6.57 22.05C6.24 22.22 5.84 22.19 5.54 21.97C5.24 21.75 5.09 21.39 5.15 21.03L6.11 15.43C6.17 15.11 6.06 14.78 5.83 14.55L1.76 10.58C1.5 10.33 1.4 9.95 1.51 9.61C1.62 9.27 1.92 9.02 2.28 8.97L7.9 8.15C8.22 8.1 8.5 7.9 8.65 7.61L11.16 2.52C11.32 2.2 11.65 2 12 2ZM12 7.8C10.78 7.8 9.8 8.78 9.8 10C9.8 11.22 12 14.2 12 14.2C12 14.2 14.2 11.22 14.2 10C14.2 8.78 13.22 7.8 12 7.8Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="22.6"
y1="22.2"
x2="1.4"
y2="2"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
case "success": case "success":
return ( return (
<svg viewBox="0 0 13 13" fill="none" aria-hidden="true" {...props}> <svg viewBox="0 0 13 13" fill="none" aria-hidden="true" {...props}>

5
src/data/languages.ts

@ -5,6 +5,7 @@ export const LANGUAGES_EN = [
"Arabic", "Arabic",
"Armenian", "Armenian",
"Azerbaijani", "Azerbaijani",
"Balochi",
"Bengali", "Bengali",
"Bosnian", "Bosnian",
"Bulgarian", "Bulgarian",
@ -36,6 +37,7 @@ export const LANGUAGES_EN = [
"Kazakh", "Kazakh",
"Khmer", "Khmer",
"Korean", "Korean",
"Kurdish",
"Kyrgyz", "Kyrgyz",
"Latvian", "Latvian",
"Lithuanian", "Lithuanian",
@ -46,6 +48,7 @@ export const LANGUAGES_EN = [
"Nepali", "Nepali",
"Norwegian", "Norwegian",
"Pashto", "Pashto",
"Persian",
"Persian (Farsi)", "Persian (Farsi)",
"Polish", "Polish",
"Portuguese", "Portuguese",
@ -94,6 +97,7 @@ export const LANGUAGES_FA = [
"ایتالیایی", "ایتالیایی",
"ایرلندی", "ایرلندی",
"ایسلندی", "ایسلندی",
"بلوچی",
"بنگالی", "بنگالی",
"بوسنیایی", "بوسنیایی",
"بلغاری", "بلغاری",
@ -125,6 +129,7 @@ export const LANGUAGES_FA = [
"قزاقی", "قزاقی",
"قرقیزی", "قرقیزی",
"کاتالان", "کاتالان",
"کردی",
"کره‌ای", "کره‌ای",
"کرواتی", "کرواتی",
"کشمیری", "کشمیری",

4
src/hooks/marriage/use-form-schema.ts

@ -25,6 +25,10 @@ export interface FormQuestion {
validation: Record<string, any>; validation: Record<string, any>;
ui_config: Record<string, any>; ui_config: Record<string, any>;
logic: Record<string, any> | null; logic: Record<string, any> | null;
visibility?: Record<string, any> | null;
conditional_rule?: Record<string, any> | null;
required_when?: Record<string, any> | null;
audience?: Record<string, any> | null;
is_visible: boolean; is_visible: boolean;
order: number; order: number;
options: FormOption[]; options: FormOption[];

1
src/hooks/marriage/use-marriage-config.ts

@ -7,6 +7,7 @@ export type MarriageConfig = {
intro_video_url: string; intro_video_url: string;
intro_video_thumbnail_url: string; intro_video_thumbnail_url: string;
video_step_2_url?: string; video_step_2_url?: string;
video_step_2_thumbnail_url?: string;
}; };
export async function getMarriageConfig() { export async function getMarriageConfig() {

203
src/lib/conditional-rules.test.ts

@ -0,0 +1,203 @@
import { describe, it, expect } from "vitest";
import {
canonicalRule,
ruleMatches,
isQuestionVisible,
isQuestionRequired,
} from "./conditional-rules";
import type { QuestionField } from "./schema-adapter";
describe("Conditional Rules Evaluator", () => {
const dummyQuestion: QuestionField = {
id: "child.q",
title: "Child Question",
type: "text",
order: 1,
required: false,
baseRequired: false,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", range: [0, 0], options: [] },
options: [],
};
it("should evaluate single trigger option match correctly", () => {
const rule = {
parent_question_id: "appearance_health.physical_health_status",
trigger_option_ids: [
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
"appearance_health.physical_health_status.i_have_a_physical_deformity_disability_or_limitation",
],
operator: "any_of",
};
// 1. When parent is not answered
expect(ruleMatches(rule, {})).toBe(false);
// 2. When parent is answered with non-matching option
expect(
ruleMatches(rule, {
"appearance_health.physical_health_status": {
value: "i_am_in_perfect_health",
option_id: "appearance_health.physical_health_status.i_am_in_perfect_health",
},
}),
).toBe(false);
// 3. When parent is answered with matching option (full slug)
expect(
ruleMatches(rule, {
"appearance_health.physical_health_status": {
value: "i_have_a_specific_or_chronic_illness",
option_id:
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
},
}),
).toBe(true);
// 4. When parent value only contains the short value
expect(
ruleMatches(rule, {
"appearance_health.physical_health_status": {
value: "i_have_a_physical_deformity_disability_or_limitation",
},
}),
).toBe(true);
});
it("should evaluate complex nested conditions (e.g. number of children rule)", () => {
// marital_history.number_of_children:
// parent: children_and_guardianship_status (have_children_living_with_me, have_children_not_living_with_me)
// conditions: current_marital_status in (divorced_after_living_together, widowed)
const rule = {
parent_question_id: "marital_history.children_and_guardianship_status",
trigger_option_ids: [
"marital_history.children_and_guardianship_status.have_children_living_with_me",
"marital_history.children_and_guardianship_status.have_children_not_living_with_me",
],
operator: "any_of",
conditions: [
{
parent_question_id: "marital_history.current_marital_status",
trigger_option_ids: [
"marital_history.current_marital_status.divorced_after_living_together",
"marital_history.current_marital_status.widowed",
],
operator: "any_of",
},
],
conditions_operator: "all_of",
root_operator: "all_of",
};
// Case 1: Single, no children -> false
expect(
ruleMatches(rule, {
"marital_history.current_marital_status": {
value: "single_never_married",
},
"marital_history.children_and_guardianship_status": {
value: "no_children",
},
}),
).toBe(false);
// Case 2: Divorced, but no children -> false
expect(
ruleMatches(rule, {
"marital_history.current_marital_status": {
value: "divorced_after_living_together",
option_id:
"marital_history.current_marital_status.divorced_after_living_together",
},
"marital_history.children_and_guardianship_status": {
value: "no_children",
option_id:
"marital_history.children_and_guardianship_status.no_children",
},
}),
).toBe(false);
// Case 3: Divorced AND has children -> true
expect(
ruleMatches(rule, {
"marital_history.current_marital_status": {
value: "divorced_after_living_together",
option_id:
"marital_history.current_marital_status.divorced_after_living_together",
},
"marital_history.children_and_guardianship_status": {
value: "have_children_living_with_me",
option_id:
"marital_history.children_and_guardianship_status.have_children_living_with_me",
},
}),
).toBe(true);
});
it("should evaluate audience rules by gender and age", () => {
const questionForMen: QuestionField = {
...dummyQuestion,
id: "education_career.ability_to_support_marriage_expenses",
audience: {
genders: ["male"],
},
};
const questionForWomen: QuestionField = {
...dummyQuestion,
id: "beliefs_lifestyle.makeup_in_public",
audience: {
genders: ["female"],
},
};
// Male user
expect(isQuestionVisible(questionForMen, {}, { gender: "male" })).toBe(true);
expect(isQuestionVisible(questionForWomen, {}, { gender: "male" })).toBe(false);
// Female user
expect(isQuestionVisible(questionForMen, {}, { gender: "female" })).toBe(false);
expect(isQuestionVisible(questionForWomen, {}, { gender: "female" })).toBe(true);
});
it("should evaluate dynamic requirement via requiredWhen", () => {
const q: QuestionField = {
...dummyQuestion,
required: false,
baseRequired: false,
requiredWhen: {
parent_question_id: "family_background.parents_marital_status",
trigger_option_ids: [
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description",
],
operator: "any_of",
},
conditionalRule: {
parent_question_id: "family_background.parents_marital_status",
trigger_option_ids: [
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description",
],
operator: "any_of",
},
};
// When condition not met -> not visible, not required
expect(isQuestionVisible(q, {})).toBe(false);
expect(isQuestionRequired(q, {})).toBe(false);
// When condition met -> visible AND required
const matchingAnswers = {
"family_background.parents_marital_status": {
value:
"i_have_special_family_circumstances_and_will_provide_the_details_in_the_description",
option_id:
"family_background.parents_marital_status.i_have_special_family_circumstances_and_will_provide_the_details_in_the_description",
},
};
expect(isQuestionVisible(q, matchingAnswers)).toBe(true);
expect(isQuestionRequired(q, matchingAnswers)).toBe(true);
});
});

304
src/lib/conditional-rules.ts

@ -0,0 +1,304 @@
import type { QuestionField } from "./schema-adapter";
export type CanonicalRule = {
parent_question_id?: string;
trigger_option_ids?: string[];
operator?: "any_of" | "all_of" | "equals" | "exists";
clear_answer_when_hidden?: boolean;
audience?: {
genders?: string[];
minAge?: number;
maxAge?: number;
};
conditions?: CanonicalRule[];
conditions_operator?: "any_of" | "all_of";
root_operator?: "any_of" | "all_of";
dependsOn?: {
key?: string;
values?: string[];
};
};
export type UserContext = {
gender?: string | null;
age?: number | null;
};
export function canonicalRule(rule: any): CanonicalRule | null {
if (!rule || typeof rule !== "object") {
return null;
}
// If wrapped in dependsOn (legacy)
if (rule.dependsOn && !rule.parent_question_id) {
return {
dependsOn: rule.dependsOn,
};
}
const operator = ["any_of", "all_of", "equals", "exists"].includes(
rule.operator,
)
? rule.operator
: "any_of";
let optionIds: string[] = [];
if (Array.isArray(rule.trigger_option_ids)) {
optionIds = rule.trigger_option_ids.map(String);
} else if (rule.trigger_option_ids !== undefined && rule.trigger_option_ids !== null) {
optionIds = [String(rule.trigger_option_ids)];
}
const result: CanonicalRule = {
parent_question_id: rule.parent_question_id || rule.parentQuestionId,
trigger_option_ids: optionIds,
operator,
clear_answer_when_hidden: Boolean(rule.clear_answer_when_hidden),
};
if (rule.audience && typeof rule.audience === "object") {
result.audience = rule.audience;
}
if (Array.isArray(rule.conditions)) {
result.conditions = rule.conditions
.map(canonicalRule)
.filter((c: CanonicalRule | null): c is CanonicalRule => c !== null);
result.conditions_operator =
rule.conditions_operator === "any_of" ? "any_of" : "all_of";
result.root_operator =
rule.root_operator === "any_of" ? "any_of" : "all_of";
}
return result;
}
export function isAnswerPresent(answer: any): boolean {
if (answer === undefined || answer === null) {
return false;
}
const val = typeof answer === "object" && "value" in answer ? answer.value : answer;
if (val === undefined || val === null) {
return false;
}
if (typeof val === "string") {
return val.trim().length > 0;
}
if (Array.isArray(val)) {
return val.length > 0;
}
if (typeof val === "object") {
return Object.keys(val).length > 0;
}
return true;
}
function getSelectedOptionTokens(answer: any): Set<string> {
const tokens = new Set<string>();
if (!answer) return tokens;
const rawOptionId =
typeof answer === "object" && "option_id" in answer
? answer.option_id
: undefined;
const rawValue =
typeof answer === "object" && "value" in answer ? answer.value : answer;
const addToken = (item: any) => {
if (item === undefined || item === null) return;
const str = String(item).trim();
if (!str) return;
tokens.add(str.toLowerCase());
// If it has a dot prefix like "sec.q.opt", also add the suffix "opt"
const lastDot = str.lastIndexOf(".");
if (lastDot !== -1 && lastDot < str.length - 1) {
tokens.add(str.slice(lastDot + 1).toLowerCase());
}
};
if (Array.isArray(rawOptionId)) {
rawOptionId.forEach(addToken);
} else if (rawOptionId !== undefined) {
addToken(rawOptionId);
}
if (Array.isArray(rawValue)) {
rawValue.forEach(addToken);
} else if (rawValue !== undefined) {
addToken(rawValue);
}
return tokens;
}
export function matchesAudience(
audience: { genders?: string[]; minAge?: number; maxAge?: number } | undefined,
context?: UserContext,
): boolean {
if (!audience || typeof audience !== "object") {
return true;
}
if (audience.genders && audience.genders.length > 0) {
if (context?.gender && !audience.genders.includes(context.gender)) {
return false;
}
}
if (audience.minAge !== undefined && context?.age !== undefined && context.age !== null) {
if (context.age < audience.minAge) {
return false;
}
}
if (audience.maxAge !== undefined && context?.age !== undefined && context.age !== null) {
if (context.age > audience.maxAge) {
return false;
}
}
return true;
}
export function ruleMatches(
rawRule: any,
answers: Record<string, any>,
context?: UserContext,
): boolean {
const rule = canonicalRule(rawRule);
if (!rule) {
return true;
}
if (rule.audience && !matchesAudience(rule.audience, context)) {
return false;
}
// Handle legacy dependsOn
if (rule.dependsOn && rule.dependsOn.key) {
const parentId = rule.dependsOn.key;
const answer = answers[parentId];
if (!isAnswerPresent(answer)) {
return false;
}
const expectedValues = (rule.dependsOn.values || []).map((v) =>
String(v).toLowerCase().trim(),
);
const actualTokens = getSelectedOptionTokens(answer);
const hasMatch = expectedValues.some((v) => actualTokens.has(v));
return hasMatch;
}
let mainMatches = true;
const parentId = rule.parent_question_id;
if (parentId) {
const answer = answers[parentId];
const operator = rule.operator || "any_of";
if (operator === "exists") {
mainMatches = isAnswerPresent(answer);
} else if (!isAnswerPresent(answer)) {
mainMatches = false;
} else {
const actualTokens = getSelectedOptionTokens(answer);
const expectedIds = (rule.trigger_option_ids || []).map((id) =>
String(id).toLowerCase().trim(),
);
const isTokenMatched = (expectedId: string) => {
if (actualTokens.has(expectedId)) return true;
const lastDot = expectedId.lastIndexOf(".");
if (lastDot !== -1 && lastDot < expectedId.length - 1) {
const suffix = expectedId.slice(lastDot + 1);
if (actualTokens.has(suffix)) return true;
}
return false;
};
if (operator === "all_of") {
mainMatches = expectedIds.length > 0 && expectedIds.every(isTokenMatched);
} else if (operator === "equals") {
mainMatches =
expectedIds.length > 0 &&
expectedIds.every(isTokenMatched) &&
actualTokens.size <= expectedIds.length * 2;
} else {
// default: "any_of"
mainMatches = expectedIds.some(isTokenMatched);
}
}
}
if (rule.conditions && rule.conditions.length > 0) {
const subMatches = rule.conditions.map((cond) =>
ruleMatches(cond, answers, context),
);
const condOperator = rule.conditions_operator || "all_of";
const conditionsResult =
condOperator === "any_of"
? subMatches.some(Boolean)
: subMatches.every(Boolean);
if (!parentId) {
return conditionsResult;
}
const rootOp = rule.root_operator || "all_of";
return rootOp === "any_of"
? mainMatches || conditionsResult
: mainMatches && conditionsResult;
}
return mainMatches;
}
export function isQuestionVisible(
question: QuestionField,
answers: Record<string, any>,
context?: UserContext,
): boolean {
// 1. Audience check
if (question.audience && !matchesAudience(question.audience, context)) {
return false;
}
// 2. Canonical visibility / conditional rule
const rule =
question.visibility ||
question.conditionalRule ||
question.logic;
if (rule) {
return ruleMatches(rule, answers, context);
}
if (question.isVisible !== undefined) {
return question.isVisible;
}
return true;
}
export function isQuestionRequired(
question: QuestionField,
answers: Record<string, any>,
context?: UserContext,
): boolean {
if (!isQuestionVisible(question, answers, context)) {
return false;
}
if (question.required || question.baseRequired) {
return true;
}
if (question.requiredWhen) {
if (question.requiredWhen.genders || question.requiredWhen.minAge || question.requiredWhen.maxAge) {
return matchesAudience(question.requiredWhen, context);
}
return ruleMatches(question.requiredWhen, answers, context);
}
return false;
}

22
src/lib/schema-adapter-overview.test.ts

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { convertOverviewToFrontendItems } from "./schema-adapter";
import { convertOverviewToFrontendItems, resolveSectionIcon } from "./schema-adapter";
import type { FormOverviewResponse } from "@/hooks/marriage/use-form-schema"; import type { FormOverviewResponse } from "@/hooks/marriage/use-form-schema";
describe("convertOverviewToFrontendItems", () => { describe("convertOverviewToFrontendItems", () => {
@ -14,7 +14,7 @@ describe("convertOverviewToFrontendItems", () => {
}, },
sections: [ sections: [
{ {
id: "personal",
id: "personal_identity",
title: "Personal", title: "Personal",
icon: "user-circle", icon: "user-circle",
is_required: true, is_required: true,
@ -28,11 +28,27 @@ describe("convertOverviewToFrontendItems", () => {
expect(convertOverviewToFrontendItems(overview)).toEqual([ expect(convertOverviewToFrontendItems(overview)).toEqual([
expect.objectContaining({ expect.objectContaining({
slug: "personal",
slug: "personal_identity",
icon: "profile",
progress: 50, progress: 50,
questions: [], questions: [],
checkpoints: [], checkpoints: [],
}), }),
]); ]);
}); });
it("resolves distinct contextual icons based on section slug and backend icon", () => {
expect(resolveSectionIcon("personal_identity")).toBe("profile");
expect(resolveSectionIcon("contact_residence")).toBe("contact");
expect(resolveSectionIcon("appearance_health")).toBe("health");
expect(resolveSectionIcon("education_career")).toBe("education");
expect(resolveSectionIcon("family_background")).toBe("family");
expect(resolveSectionIcon("marital_history")).toBe("marriage");
expect(resolveSectionIcon("beliefs_lifestyle")).toBe("lifestyle");
expect(resolveSectionIcon("spouse_criteria")).toBe("criteria");
expect(resolveSectionIcon("documents_verification")).toBe("verification");
expect(resolveSectionIcon("personality_test")).toBe("personality");
expect(resolveSectionIcon("glasser_5_needs_test")).toBe("glasser");
});
}); });

113
src/lib/schema-adapter.ts

@ -8,12 +8,57 @@ import { defaultLocale, type Locale } from "@/translations/config";
export type QuestionCardIcon = export type QuestionCardIcon =
| "profile" | "profile"
| "contact"
| "health"
| "education" | "education"
| "family"
| "marriage"
| "lifestyle"
| "criteria"
| "verification"
| "personality"
| "glasser"
| "details" | "details"
| "checklist" | "checklist"
| "contact"
| "family_marital"; | "family_marital";
export const sectionSlugIconMap: Record<string, QuestionCardIcon> = {
personal_info: "profile",
personal_identity: "profile",
contact_residence_family_communication: "contact",
contact_residence: "contact",
appearance_health_activity: "health",
appearance_health: "health",
education_career_economic_status: "education",
education_career: "education",
family_background: "family",
marital_history_children: "marriage",
marital_history: "marriage",
beliefs_lifestyle_boundaries: "lifestyle",
beliefs_lifestyle: "lifestyle",
future_spouse_criteria: "criteria",
spouse_criteria: "criteria",
identity_verification: "verification",
documents_verification: "verification",
personality_test: "personality",
cattell_test: "personality",
glasser_5_needs_test: "glasser",
glasser_test: "glasser",
};
export function resolveSectionIcon(
slug: string,
backendIcon?: string,
): QuestionCardIcon {
if (slug && sectionSlugIconMap[slug]) {
return sectionSlugIconMap[slug];
}
if (backendIcon && iconMap[backendIcon]) {
return iconMap[backendIcon];
}
return "details";
}
export type QuestionExtras = { export type QuestionExtras = {
placeHolder: string; placeHolder: string;
range: [number, number]; range: [number, number];
@ -41,8 +86,11 @@ export type QuestionField = {
validation?: any; validation?: any;
ui_config?: any; ui_config?: any;
extras: QuestionExtras; extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
audience?: QuestionAudienceRule | any;
requiredWhen?: any;
conditionalRule?: any;
visibility?: any;
logic?: any;
showGuardianNotice?: boolean; showGuardianNotice?: boolean;
options: { options: {
id: string; id: string;
@ -69,16 +117,59 @@ export type QuestionListItem = {
const iconMap: Record<string, QuestionCardIcon> = { const iconMap: Record<string, QuestionCardIcon> = {
"user-circle": "profile", "user-circle": "profile",
user: "profile",
person: "profile",
school: "education", school: "education",
"heart-handshake": "details",
education: "education",
"heart-handshake": "marriage",
"heart-pulse": "health",
activity: "health",
"file-text": "contact", "file-text": "contact",
contact: "contact",
users: "family",
family: "family",
compass: "lifestyle",
"target-heart": "criteria",
"shield-check": "verification",
"shield": "verification",
brain: "personality",
"layout-grid": "checklist", "layout-grid": "checklist",
"star-half": "glasser",
star: "glasser",
}; };
export function mapBackendQuestionToFrontend( export function mapBackendQuestionToFrontend(
bq: FormQuestion, bq: FormQuestion,
index: number, index: number,
): QuestionField { ): QuestionField {
const minVal = bq.validation?.min;
const maxVal = bq.validation?.max;
const rangeVal = bq.validation?.range;
const uiRange = bq.ui_config?.range;
const uiMin = bq.ui_config?.min;
const uiMax = bq.ui_config?.max;
let range: [number, number] = [0, 0];
if (
Array.isArray(uiRange) &&
uiRange.length === 2 &&
(uiRange[0] !== 0 || uiRange[1] !== 0)
) {
range = [Number(uiRange[0]), Number(uiRange[1])];
} else if (
Array.isArray(rangeVal) &&
rangeVal.length === 2 &&
(rangeVal[0] !== 0 || rangeVal[1] !== 0)
) {
range = [Number(rangeVal[0]), Number(rangeVal[1])];
} else if (minVal !== undefined && maxVal !== undefined) {
range = [Number(minVal), Number(maxVal)];
} else if (uiMin !== undefined && uiMax !== undefined) {
range = [Number(uiMin), Number(uiMax)];
} else if (Array.isArray(uiRange) && uiRange.length === 2) {
range = [Number(uiRange[0]), Number(uiRange[1])];
}
return { return {
id: bq.id, id: bq.id,
title: bq.title || "Untitled", title: bq.title || "Untitled",
@ -95,10 +186,16 @@ export function mapBackendQuestionToFrontend(
extras: { extras: {
placeHolder: bq.placeholder || "", placeHolder: bq.placeholder || "",
options: bq.options?.map((o) => o.label) || [], options: bq.options?.map((o) => o.label) || [],
range: bq.ui_config?.range || [0, 0],
range,
noSearch: bq.ui_config?.noSearch, noSearch: bq.ui_config?.noSearch,
}, },
showGuardianNotice: bq.show_guardian_notice, showGuardianNotice: bq.show_guardian_notice,
audience: bq.audience || undefined,
requiredWhen: bq.required_when || undefined,
conditionalRule:
bq.conditional_rule || bq.visibility || bq.logic || undefined,
visibility: bq.visibility || bq.conditional_rule || undefined,
logic: bq.logic || undefined,
options: [...(bq.options || [])].sort( options: [...(bq.options || [])].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0), (a, b) => (a.order ?? 0) - (b.order ?? 0),
), ),
@ -133,7 +230,7 @@ export function mapBackendSectionToFrontend(
? `${section.estimated_minutes} min` ? `${section.estimated_minutes} min`
: "5 min", : "5 min",
progress: progress, progress: progress,
icon: iconMap[section.icon] ?? "details",
icon: resolveSectionIcon(section.id, section.icon),
required: section.is_required, required: section.is_required,
showInfoBadge: false, showInfoBadge: false,
summary: "", summary: "",
@ -174,8 +271,8 @@ export function convertOverviewToFrontendItems(
estimate: section.estimated_minutes estimate: section.estimated_minutes
? `${section.estimated_minutes} min` ? `${section.estimated_minutes} min`
: "5 min", : "5 min",
progress: section.progress.completion_percent,
icon: iconMap[section.icon] ?? "details",
progress: section.progress?.completion_percent ?? 0,
icon: resolveSectionIcon(section.id, section.icon),
required: section.is_required, required: section.is_required,
showInfoBadge: false, showInfoBadge: false,
summary: "", summary: "",

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/02_contact_residence_family_communication.png

After

Width: 1254  |  Height: 1254  |  Size: 987 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/03_physical_appearance_health.png

After

Width: 1254  |  Height: 1254  |  Size: 952 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/04_education_career_economic_status.png

After

Width: 1254  |  Height: 1254  |  Size: 980 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/05_family_background.png

After

Width: 1254  |  Height: 1254  |  Size: 1.0 MiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/06_marital_status_marriage_history_children.png

After

Width: 1254  |  Height: 1254  |  Size: 805 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/07_beliefs_lifestyle_personal_boundaries.png

After

Width: 1254  |  Height: 1254  |  Size: 1018 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/08_future_spouse_criteria_red_lines.png

After

Width: 1254  |  Height: 1254  |  Size: 899 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/09_identity_verification_documents.png

After

Width: 1254  |  Height: 1254  |  Size: 1021 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/10_personality_test.png

After

Width: 1254  |  Height: 1254  |  Size: 1021 KiB

BIN
temp_extracted_icons/simplified_profile_icons_02_to_11/11_glasser_5_needs_test.png

After

Width: 1254  |  Height: 1254  |  Size: 794 KiB

Loading…
Cancel
Save