diff --git a/simplified_profile_icons_02_to_11.zip b/simplified_profile_icons_02_to_11.zip new file mode 100644 index 0000000..e38c6b3 Binary files /dev/null and b/simplified_profile_icons_02_to_11.zip differ diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 0949d43..0584a18 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -44,6 +44,8 @@ import { mapBackendSectionToFrontend, type QuestionField, } 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 { useI18n } from "@/translations/provider"; import { useCurrentProfileId } from "@/hooks/use-current-profile-id"; @@ -87,31 +89,56 @@ function getQuestionStorageKey(slug: string, profileId: number | null) { } function QuestionFlowWrapper({ - visibleQuestions, + questions, itemSlug, - dobQuestion, continueLabel, questionsListHref, onExit, }: { - visibleQuestions: QuestionField[]; + questions: QuestionField[]; itemSlug: string; - dobQuestion?: QuestionField; - requiredQuestionsCount: number; continueLabel: string; questionsListHref: string; onExit?: () => void; }) { - 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( () => dynamicQuestions.filter((q) => q.required).length, [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 ( { - 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(() => { if (!isSchemaLoading && !isSchemaError && !item) { @@ -816,18 +827,13 @@ export default function QuestionDetailClient({ ); } - const dobQuestion = visibleQuestions.find( - (question) => - question.ui_config?.isDob === true || question.type === "date", - ); - return ( <>
@@ -860,10 +866,8 @@ export default function QuestionDetailClient({
void; }) { const [hasSeenSheet, setHasSeenSheet] = useState(true); + const [isAutoOpenDismissed, setIsAutoOpenDismissed] = useState(false); const hasNoProgression = useMemo(() => { if (!sections?.length) { @@ -42,8 +47,6 @@ export default function SectionsRequest({ ); }, [sections]); - const isOpen = Boolean(sections) && hasNoProgression && !hasSeenSheet; - useEffect(() => { try { 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 { window.localStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true"); window.sessionStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true"); } catch (e) { console.warn("Storage is not accessible:", e); } - }, [isOpen]); + controlledOnClose?.(); + }; - if (!isOpen) { + if (!isSheetOpen) { return null; } @@ -104,7 +110,9 @@ export default function SectionsRequest({ Got it )} + onClose={handleClose} className="text-left" /> ); } + diff --git a/src/components/Componentes/loading-border-spinner.tsx b/src/components/Componentes/loading-border-spinner.tsx index c3d5af5..83db786 100644 --- a/src/components/Componentes/loading-border-spinner.tsx +++ b/src/components/Componentes/loading-border-spinner.tsx @@ -1,25 +1,58 @@ 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, 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, 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({ + size, + variant, className = "", ...props -}: ComponentProps<"span">) { +}: LoadingBorderSpinnerProps) { const hasBorder = className.split(" ").some((c) => c.startsWith("border-")); - const borderClasses = hasBorder + const defaultBorderClasses = hasBorder ? "" - : "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400"; + : variant + ? variantClasses[variant] + : "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400"; const hasSize = className .split(" ") .some( (c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"), ); - const sizeClasses = hasSize ? "" : "size-5"; + const defaultSizeClasses = hasSize + ? "" + : size + ? sizeClasses[size] + : "size-5"; return ( ); } + diff --git a/src/components/Componentes/loading-icon-spinner.tsx b/src/components/Componentes/loading-icon-spinner.tsx index d17d3d2..9c3e12d 100644 --- a/src/components/Componentes/loading-icon-spinner.tsx +++ b/src/components/Componentes/loading-icon-spinner.tsx @@ -6,12 +6,15 @@ export function LoadingIconSpinner({ }: ComponentProps<"svg">) { return ( + ); } + diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 3dd58f6..739633a 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -61,6 +61,7 @@ type QuestionAnswersContextValue = { isLoading: boolean; setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void; backendFields: MarriageField[]; + answers: QuestionAnswersByKey; }; type QuestionAnswersProviderProps = { @@ -324,6 +325,7 @@ export function QuestionAnswersProvider({ const storageKeyRef = useRef(storageKey); const slugRef = useRef(slug); const backendFieldsRef = useRef([]); + const versionRef = useRef(undefined); const dirtyKeysRef = useRef(new Set()); const { data: profile } = useMarriageProfileQuery(); @@ -338,12 +340,14 @@ export function QuestionAnswersProvider({ slugRef.current = slug; questionsRef.current = questions; backendFieldsRef.current = serverSectionData?.data || []; + versionRef.current = serverSectionData?.version; }, [ answers, hasPendingSync, slug, questions, serverSectionData?.data, + serverSectionData?.version, ]); useEffect(() => { @@ -559,6 +563,7 @@ export function QuestionAnswersProvider({ const payload = { ...fullPayload, fields: pendingFields, + version: versionRef.current, }; const revision = answersRevisionRef.current; @@ -602,6 +607,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 // completed. A forced exit waits for and saves that newer revision too. if (revision !== answersRevisionRef.current) { @@ -611,9 +620,6 @@ export function QuestionAnswersProvider({ return; } - payload.fields.forEach((field) => { - dirtyKeysRef.current.delete(field.key); - }); if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); @@ -657,7 +663,7 @@ export function QuestionAnswersProvider({ const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), ); - const payload = { ...fullPayload, fields: pendingFields }; + const payload = { ...fullPayload, fields: pendingFields, version: versionRef.current }; const revision = answersRevisionRef.current; if (payload.fields.length === 0) { @@ -682,6 +688,7 @@ export function QuestionAnswersProvider({ fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify({ answers: answersPayload, + version: payload.version, }), credentials: "include", headers, @@ -761,6 +768,7 @@ export function QuestionAnswersProvider({ isLoading: isLoadingData, setAnswerValue, backendFields: serverSectionData?.data || [], + answers, }), [ flushAnswers, @@ -770,6 +778,7 @@ export function QuestionAnswersProvider({ isLoadingData, setAnswerValue, serverSectionData?.data, + answers, ], ); diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 8eee18f..5955276 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -1,7 +1,11 @@ import Link from "next/link"; import { useEffect, useRef } from "react"; 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 { useI18n } from "@/translations/provider"; import { UiIcon, type UiIconName } from "./ui-icon"; @@ -22,11 +26,19 @@ const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // no network request, present in the first rendered markup. const iconNameMap: Record = { profile: "person", + contact: "contact", + health: "health", education: "education", + family: "family", + marriage: "marriage", + lifestyle: "lifestyle", + criteria: "criteria", + verification: "verification", + personality: "personality", + glasser: "glasser", details: "details", checklist: "checklist", - contact: "contact", - family_marital: "details", + family_marital: "marriage", }; export function QuestionCard({ @@ -43,7 +55,8 @@ export function QuestionCard({ ? Math.max(0, Math.min(Math.round(progress), 100)) : 0; 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(null); useEffect(() => { @@ -91,14 +104,12 @@ 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" >
-
-
-
+
+
diff --git a/src/components/Componentes/question-phone.test.tsx b/src/components/Componentes/question-phone.test.tsx index c36b282..c9c18e3 100644 --- a/src/components/Componentes/question-phone.test.tsx +++ b/src/components/Componentes/question-phone.test.tsx @@ -1,82 +1,210 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +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 } from "./question-phone"; +import { QuestionPhone, resetGeoPhoneStateForTesting } from "./question-phone"; -afterEach(() => { - cleanup(); +let answerMap: Record = {}; +const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => { + answerMap[q.id] = val; }); vi.mock("@/translations/provider", () => ({ useI18n: () => ({ + locale: "en", dictionary: { - "Select country": "انتخاب کشور", - Close: "بستن", - Confirm: "تایید", + "Select country": "Select country", + Close: "Close", + Confirm: "Confirm", }, - locale: "fa", }), })); vi.mock("./question-answer-storage", () => ({ useQuestionAnswers: () => ({ - getAnswerValue: () => null, - setAnswerValue: vi.fn(), + getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null, + setAnswerValue: mockSetAnswerValue, isLoading: false, }), })); -describe("QuestionPhone Component", () => { - const mockQuestion = { - id: "personal_contact_number", - title: "شماره تماس شخصی", - type: "phone", - extras: { - placeHolder: "+98 9123456789", - }, - } as QuestionField; +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(); + }); - it("renders phone input and opens sheet on country code click", async () => { - render(); + afterEach(() => { + cleanup(); + }); - const countryButton = screen.getByRole("button", { name: /\+98/i }); - expect(countryButton).toBeInTheDocument(); + 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; + }); - fireEvent.click(countryButton); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + ipPromise.then( + (data) => + ({ + ok: true, + json: async () => data, + }) as unknown as Response, + ), + ); - const dialog = screen.getByRole("dialog"); - expect(dialog).toBeInTheDocument(); - expect(screen.getByText("انتخاب کشور")).toBeInTheDocument(); + const { container } = render(); - // Close button exists in header - const closeBtn = screen.getByLabelText("Close"); - expect(closeBtn).toBeInTheDocument(); + const shimmerElements = container.querySelectorAll(".shimmer-bg"); + expect(shimmerElements.length).toBeGreaterThan(0); + const input = screen.getByRole("textbox"); + expect(input.classList.contains("shimmer-bg")).toBe(false); - // Search input is present with translated placeholder - const searchInput = screen.getByPlaceholderText("جستجو..."); - expect(searchInput).toBeInTheDocument(); + await act(async () => { + resolveIpFetch({ country_calling_code: "+98" }); + }); + + await waitFor(() => { + expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); + expect(screen.getByText("+98")).toBeDefined(); + expect(screen.getByText("🇮🇷")).toBeDefined(); + }); }); - it("filters countries when searching and selects a country", async () => { + it("shows default country code when IP request fails", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("Network failure"), + ); + const { container } = render( - , + , ); - const triggerButton = container.querySelector("button")!; - fireEvent.click(triggerButton); + await waitFor(() => { + expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); + expect(screen.getByText("+44")).toBeDefined(); + expect(screen.getByText("🇬🇧")).toBeDefined(); + }); + }); - const searchInput = screen.getByPlaceholderText("جستجو..."); - fireEvent.change(searchInput, { target: { value: "ایران" } }); + 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, + ), + ); - // Option should be rendered - const iranOption = screen.getByRole("button", { name: /ایران/i }); - expect(iranOption).toBeInTheDocument(); + render( + <> + + + , + ); - fireEvent.click(iranOption); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveIpFetch({ country_calling_code: "+98" }); + }); - // After selection, dialog closes await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + 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(); + + 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(); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "123456" } }); + + await act(async () => { + resolveIpFetch({ country_calling_code: "+98" }); + }); + + expect(screen.getByDisplayValue("123456")).toBeDefined(); + }); }); diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index 95e3f09..f490360 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -25,6 +25,134 @@ type PhoneValueParts = { const phoneUtil = PhoneNumberUtil.getInstance(); +// Module-level singleton state for IP phone country resolution +let cachedGeoCountryCode: string | null = null; +let geoIpPromise: Promise | 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 { + 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( value: unknown, ): value is MarriagePhoneFieldValue { @@ -230,15 +358,34 @@ export function QuestionPhone({ disabled, }: QuestionPhoneProps) { const { dictionary: t, locale } = useI18n(); - const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); + const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const value = getAnswerValue(question); 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(() => { if (isMarriagePhoneFieldValue(value) && value.countryCode) { return value.countryCode.startsWith("+") @@ -251,10 +398,10 @@ export function QuestionPhone({ return parts.codeValue; } } - const cached = getCachedOrSavedCode(); + const cached = getStoredGeoCode(); if (cached) return cached; return defaultCodeValue; - }, [value, defaultCodeValue, getCachedOrSavedCode]); + }, [value, defaultCodeValue]); const initialPhone = useMemo(() => { return readPhoneValue(value, defaultCodeValue).phoneValue; @@ -263,8 +410,6 @@ export function QuestionPhone({ const [codeValue, setCodeValue] = useState(initialCode); const [phoneValue, setPhoneValue] = useState(initialPhone); const lastCommittedValueRef = useRef(value); - const hasFetchedIpRef = useRef(false); - const userInteractedRef = useRef(false); const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); @@ -281,10 +426,48 @@ export function QuestionPhone({ }, []); const openSheet = useCallback(() => { - if (disabled) return; + if (disabled || isResolvingCountry) return; setIsOpen(true); 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 showInvalidState = @@ -341,6 +524,19 @@ export function QuestionPhone({ const activeFlag = useMemo(() => { if (!codeValue) return "🏳️"; 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( (c) => c.code.replace(/[^\d]/g, "") === cleanActiveCode, ); @@ -349,6 +545,19 @@ export function QuestionPhone({ const selectedCountry = useMemo(() => { 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( (country) => country.code.replace(/[^\d]/g, "") === cleanActiveCode, ); @@ -382,130 +591,28 @@ export function QuestionPhone({ } 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" && value.trim().length > 0 && - nextValue.codeValue !== defaultCodeValue) - ? nextValue.codeValue - : cachedCode || defaultCodeValue; - - const maxLen = getMaxLengthForCountry(resolvedCode); - const truncatedPhone = nextValue.phoneValue.slice(0, maxLen); - - 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; + 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); + const maxLen = getMaxLengthForCountry(resolvedCode); + setPhoneValue(nextValue.phoneValue.slice(0, maxLen)); } - // 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 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 draftValue = writePhoneValue(nextCodeValue, nextPhoneValue); @@ -526,10 +633,8 @@ export function QuestionPhone({ const handleSelectCountryCode = (selectedCode: string) => { userInteractedRef.current = true; - if (typeof window !== "undefined") { - localStorage.setItem("geoIPPhoneCode", selectedCode); - localStorage.setItem("hasCheckedGeoIPPhone", "true"); - } + setIsResolvingCountry(false); + setManuallySelectedGeoCode(selectedCode); const maxLen = getMaxLengthForCountry(selectedCode); const truncatedPhone = phoneValue.slice(0, maxLen); @@ -580,31 +685,44 @@ export function QuestionPhone({
@@ -628,10 +746,8 @@ export function QuestionPhone({ maxLength={getMaxLengthForCountry(codeValue)} onChange={(event) => { 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 maxLen = getMaxLengthForCountry(codeValue); const truncatedPhone = nextPhoneValue.slice(0, maxLen); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index ca430d5..89926b8 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { getLanguageList, LANGUAGES_EN, LANGUAGES_FA } from "@/data/languages"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { Button } from "./button"; @@ -71,7 +72,72 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { return () => window.removeEventListener("keydown", handleKeyDown); }, [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(); + + 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; diff --git a/src/components/Componentes/question-slider.test.tsx b/src/components/Componentes/question-slider.test.tsx new file mode 100644 index 0000000..d8ac6c2 --- /dev/null +++ b/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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/Componentes/question-slider.tsx b/src/components/Componentes/question-slider.tsx index 2797c2a..cc827e0 100644 --- a/src/components/Componentes/question-slider.tsx +++ b/src/components/Componentes/question-slider.tsx @@ -1,6 +1,5 @@ "use client"; -import { useLayoutEffect, useRef, useState } from "react"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; @@ -16,26 +15,107 @@ export function QuestionSlider({ disabled, }: QuestionSliderProps) { const { dictionary: t } = useI18n(); - const [min, max] = question.extras.range; - const initialValue = Math.round((min + max) / 2); const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); 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 thumbWidth = 18; - const bubbleHalfWidth = 18; const steps = (() => { 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[] = []; for (let val = min; val <= max; val += interval) { arr.push(val); } if (arr[arr.length - 1] !== max) { const lastVal = arr[arr.length - 1]; - if (max - lastVal < interval * 0.5) { + if (max - lastVal < interval * 0.6) { arr.pop(); } 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(null); - const sliderRef = useRef(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); - - setBubblePosition( - Math.min( - Math.max(thumbCenter, bubbleHalfWidth), - wrapperRect.width - bubbleHalfWidth, - ), - ); - }; - - updateBubblePosition(); + // Single-slider states + const parsedStoredValue = + typeof storedValue === "number" + ? storedValue + : typeof storedValue === "string" && + storedValue.trim() !== "" && + !Number.isNaN(Number(storedValue)) + ? Number(storedValue) + : null; - const resizeObserver = new ResizeObserver(updateBubblePosition); - resizeObserver.observe(wrapper); - resizeObserver.observe(slider); + const value = + parsedStoredValue !== null + ? Math.max(min, Math.min(max, parsedStoredValue)) + : initialValue; - return () => resizeObserver.disconnect(); - }, [progress]); + const progress = + max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0; if (isDesiredAgeRange) { const handleFromChange = (newFrom: number) => { @@ -135,8 +191,8 @@ export function QuestionSlider({ {/* First Slider (From Age) */} -
-
+
+
{t.From ?? "From"} {fromVal} @@ -147,7 +203,7 @@ export function QuestionSlider({ type="range" min={min} max={max} - step={1} + step={step} value={fromVal} onChange={(e) => handleFromChange(Number(e.target.value))} disabled={disabled} @@ -157,18 +213,18 @@ export function QuestionSlider({ }} />
-
- {steps.map((step) => { - const leftPct = ((step - min) / (max - min)) * 100; +
+ {steps.map((s) => { + const leftPct = ((s - min) / (max - min)) * 100; return ( - {step} + {s} ); })} @@ -176,8 +232,8 @@ export function QuestionSlider({
{/* Second Slider (To Age) */} -
-
+
+
{t.To ?? "To"} {toVal} @@ -188,7 +244,7 @@ export function QuestionSlider({ type="range" min={min} max={max} - step={1} + step={step} value={toVal} onChange={(e) => handleToChange(Number(e.target.value))} disabled={disabled} @@ -198,18 +254,18 @@ export function QuestionSlider({ }} />
-
- {steps.map((step) => { - const leftPct = ((step - min) / (max - min)) * 100; +
+ {steps.map((s) => { + const leftPct = ((s - min) / (max - min)) * 100; return ( - {step} + {s} ); })} @@ -227,25 +283,24 @@ export function QuestionSlider({ ].join(" ")} > -
-
+
+
{value}
- setAnswerValue( - question, Number(event.target.value), - ) + setAnswerValue(question, Number(event.target.value)) } disabled={disabled} className="question-slider-range w-full" @@ -254,21 +309,18 @@ export function QuestionSlider({ }} />
-
- {steps.map((step) => { - const leftPct = ((step - min) / (max - min)) * 100; +
+ {steps.map((stepVal) => { + const leftPct = ((stepVal - min) / (max - min)) * 100; return ( - {step} + {stepVal} ); })} diff --git a/src/components/Componentes/schema-question-flow.integration.test.tsx b/src/components/Componentes/schema-question-flow.integration.test.tsx index e8fe211..1ca4cd4 100644 --- a/src/components/Componentes/schema-question-flow.integration.test.tsx +++ b/src/components/Componentes/schema-question-flow.integration.test.tsx @@ -336,4 +336,138 @@ describe("Schema Question Flow Integration", () => { // Static text should not exist 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( + + + , + ); + + // 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(); + }); + }); }); diff --git a/src/components/Componentes/slider-slide-two.test.tsx b/src/components/Componentes/slider-slide-two.test.tsx new file mode 100644 index 0000000..0a2198b --- /dev/null +++ b/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( + + + , + ); + + 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(); + }); +}); diff --git a/src/components/Componentes/slider-slide-two.tsx b/src/components/Componentes/slider-slide-two.tsx index 55dba4c..5b117f5 100644 --- a/src/components/Componentes/slider-slide-two.tsx +++ b/src/components/Componentes/slider-slide-two.tsx @@ -12,6 +12,13 @@ export function SliderSlideTwo({ index }: SliderSlideProps) { const [isPlayerOpen, setIsPlayerOpen] = useState(false); 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 (
setIsPlayerOpen(true)} > -
+
+
play
-
-
+
+
video
-

+

We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims

+ setIsPlayerOpen(false)} diff --git a/src/components/Componentes/ui-icon.tsx b/src/components/Componentes/ui-icon.tsx index 6e558fc..e343fbd 100644 --- a/src/components/Componentes/ui-icon.tsx +++ b/src/components/Componentes/ui-icon.tsx @@ -23,6 +23,14 @@ export type UiIconName = | "details" | "checklist" | "contact" + | "health" + | "family" + | "marriage" + | "lifestyle" + | "criteria" + | "verification" + | "personality" + | "glasser" | "success" | "diamond"; @@ -254,6 +262,198 @@ export function UiIcon({ name, ...props }: UiIconProps) { ); + case "health": + return ( + + ); + + case "family": + return ( + + ); + + case "marriage": + return ( + + ); + + case "lifestyle": + return ( + + ); + + case "criteria": + return ( + + ); + + case "verification": + return ( + + ); + + case "personality": + return ( + + ); + + case "glasser": + return ( + + ); + case "success": return (