diff --git a/src/app/globals.css b/src/app/globals.css index 9baeee3..f7fbd93 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -145,22 +145,22 @@ } html { + width: 100%; height: 100%; + overflow: hidden; overscroll-behavior: none; - overscroll-behavior-y: none; } body { - min-height: 100%; + width: 100%; + height: 100%; margin: 0; display: flex; justify-content: center; + overflow: hidden; color: var(--foreground); font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; overscroll-behavior: none; - overscroll-behavior-y: none; - -webkit-overflow-scrolling: touch; - touch-action: pan-y; } html:lang(ar) body, @@ -186,17 +186,21 @@ html:lang(ar) body, .app-shell { width: 100%; - min-height: 100%; + height: 100%; + height: 100dvh; padding-inline: 17px; padding-bottom: var(--safe-bottom, 0px); box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; background-color: var(--background); background-image: var(--default-page-background-image); background-position: top; background-repeat: no-repeat; background-size: cover; overscroll-behavior: none; - overscroll-behavior-y: none; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; } html[data-web-bootstrap="pending"] .app-shell { diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index 84cb682..2ab03a4 100644 --- a/src/app/questions-list/page.tsx +++ b/src/app/questions-list/page.tsx @@ -1,41 +1,46 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IoClose } from "react-icons/io5"; -import { - getSubmitPath, - hasCompletedMarriageProfileBasics, -} from "@/lib/get-submit-path"; -import QuestionCard from "@/components/Componentes/question-card"; -import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import Button from "@/components/Componentes/button"; +import ErrorToast from "@/components/Componentes/error-toast"; +import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import InformationSheet from "@/components/Componentes/information-sheet"; import NavigationButton from "@/components/Componentes/navigation-button"; -import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage"; -import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { PageBackground } from "@/components/Componentes/page-background"; -import ErrorToast from "@/components/Componentes/error-toast"; -import { useQueryClient } from "@tanstack/react-query"; -import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; -import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage"; +import QuestionCard from "@/components/Componentes/question-card"; +import RequiredStepsCard from "@/components/Componentes/required-steps-card"; +import type { MarriageField } from "@/hooks/marriage/types"; import { getFormSection, useFormOverviewQuery, } from "@/hooks/marriage/use-form-schema"; -import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; -import type { QuestionListItem } from "@/lib/schema-adapter"; +import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; +import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { + applyProfilePatchResultToCache, + updateMarriageSectionData, +} from "@/hooks/marriage/use-section-data"; import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; +import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; +import { + getSubmitPath, + hasCompletedMarriageProfileBasics, +} from "@/lib/get-submit-path"; +import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract"; import { clearMatchStartGrace, markMatchStarted, } from "@/lib/match-start-grace"; +import type { QuestionListItem } from "@/lib/schema-adapter"; +import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; import SectionsRequest from "./sections-request"; -import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; -import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract"; export default function QuestionsListPage() { useCloseServiceOnBack(); @@ -58,7 +63,8 @@ export default function QuestionsListPage() { const isProfileRedirecting = profileTargetPath !== null && profileTargetPath !== "/questions-list" && - (!hasCompletedMarriageProfileBasics(profile) || profile?.can_edit_profile === false); + (!hasCompletedMarriageProfileBasics(profile) || + profile?.can_edit_profile === false); useEffect(() => { if (isProfileRedirecting && profileTargetPath) { @@ -153,15 +159,22 @@ export default function QuestionsListPage() { setDisplayedRequiredSections(completedRequiredSections); return; } - const direction = completedRequiredSections > displayedRequiredSections ? 1 : -1; - const distance = Math.abs(completedRequiredSections - displayedRequiredSections); - const interval = window.setInterval(() => { - setDisplayedRequiredSections((current) => { - const next = current + direction; - if (next === completedRequiredSections) window.clearInterval(interval); - return next; - }); - }, Math.max(90, Math.floor(500 / distance))); + const direction = + completedRequiredSections > displayedRequiredSections ? 1 : -1; + const distance = Math.abs( + completedRequiredSections - displayedRequiredSections, + ); + const interval = window.setInterval( + () => { + setDisplayedRequiredSections((current) => { + const next = current + direction; + if (next === completedRequiredSections) + window.clearInterval(interval); + return next; + }); + }, + Math.max(90, Math.floor(500 / distance)), + ); return () => window.clearInterval(interval); }, [completedRequiredSections, displayedRequiredSections]); @@ -186,7 +199,11 @@ export default function QuestionsListPage() { const progress = sectionProgressBySlug.get(item.slug) ?? 0; return progress >= 100; }); - }, [hasValidRequiredContract, requiredQuestionListItems, sectionProgressBySlug]); + }, [ + hasValidRequiredContract, + requiredQuestionListItems, + sectionProgressBySlug, + ]); const profileStatus = profile?.status; const isProfileSuspended = profileStatus === "suspended"; @@ -200,12 +217,24 @@ export default function QuestionsListPage() { const syncPendingAnswers = useCallback(async () => { if (!overview) return; - const { updateMarriageSectionData } = await import( - "@/hooks/marriage/use-section-data" - ); - - let currentVersion = overview.version; + const pendingSections: Array<{ + storageKey: string; + storedValue: { + current_step: number; + fields: MarriageField[]; + pending_keys: string[]; + pending_sync: boolean; + }; + fields: MarriageField[]; + slug: string; + }> = []; for (const item of questionListItems) { + if ( + item.slug === "personality_test" || + item.slug === "glasser_5_needs_test" + ) { + continue; + } const storageKey = getQuestionAnswersStorageKey(item.slug); const rawValue = window.localStorage.getItem(storageKey); if (!rawValue) continue; @@ -218,22 +247,40 @@ export default function QuestionsListPage() { ? storedValue.pending_keys : storedValue.fields.map((field: { key: string }) => field.key), ); - for (const field of storedValue.fields) { - if (!pendingKeys.has(field.key)) continue; - const result = await updateMarriageSectionData(item.slug, { - version: currentVersion, - current_step: storedValue.current_step, - fields: [field], - }); - currentVersion = result.version; - pendingKeys.delete(field.key); - storedValue.pending_keys = [...pendingKeys]; + const pendingFields = storedValue.fields.filter( + (field: { key: string }) => pendingKeys.has(field.key), + ); + if (pendingFields.length === 0) continue; + pendingSections.push({ + storageKey, + storedValue, + fields: pendingFields, + slug: item.slug, + }); + } + + if (pendingSections.length === 0) return; + const result = await updateMarriageSectionData(pendingSections[0].slug, { + version: overview.version, + current_step: pendingSections[0].storedValue.current_step, + fields: pendingSections.flatMap((section) => section.fields), + }); + applyProfilePatchResultToCache(queryClient, locale, result); + const cleared = new Set(result.cleared_answer_ids ?? []); + + for (const { storageKey, storedValue } of pendingSections) { + storedValue.fields = storedValue.fields.filter( + (field: { key: string }) => !cleared.has(field.key), + ); + storedValue.pending_sync = false; + storedValue.pending_keys = []; + if (storedValue.fields.length === 0) { + window.localStorage.removeItem(storageKey); + } else { window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); } - storedValue.pending_sync = false; - window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); } - }, [overview, questionListItems]); + }, [locale, overview, queryClient, questionListItems]); const prefetchSection = useCallback( (item: QuestionListItem) => { diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index dc0863a..3329edb 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -11,7 +11,6 @@ import { useRef, useState, } from "react"; -import type { QuestionField } from "@/lib/schema-adapter"; import { pathParam } from "@/hooks/marriage/path-param"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import type { @@ -22,10 +21,12 @@ import type { } from "@/hooks/marriage/types"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { + applyProfilePatchResultToCache, useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation, } from "@/hooks/marriage/use-section-data"; import { getApiRequestUrl } from "@/lib/http"; +import type { QuestionField } from "@/lib/schema-adapter"; const STORAGE_VERSION = 2; @@ -121,7 +122,7 @@ function createQuestionField( question: QuestionField, value: MarriageFieldValue, ): MarriageField { - let option_id = undefined; + let option_id: string | string[] | undefined; if (question.options && Array.isArray(question.options)) { if (question.type === "checkbox" && Array.isArray(value)) { @@ -484,14 +485,12 @@ export function QuestionAnswersProvider({ questionsRef.current, backendFieldsRef.current, ); - const nextDirtyKey = fullPayload.fields.find((field) => + const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), - )?.key; + ); const payload = { ...fullPayload, - fields: nextDirtyKey - ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) - : [], + fields: pendingFields, version: schemaVersionRef.current, }; const revision = answersRevisionRef.current; @@ -504,6 +503,30 @@ export function QuestionAnswersProvider({ if (result?.version) { schemaVersionRef.current = result.version; } + const cleared = new Set(result?.cleared_answer_ids ?? []); + const nextAnswers = { ...answersRef.current }; + const questionById = new Map( + questionsRef.current.map((question) => [question.id, question]), + ); + Object.entries(result?.answers ?? {}).forEach(([key, answer]) => { + const question = questionById.get(key); + if (question) { + nextAnswers[key] = { + key, + label: question.title, + type: question.type, + value: answer.option_id ?? answer.value, + option_id: answer.option_id, + } as MarriageField; + } + }); + cleared.forEach((key) => { + delete nextAnswers[key]; + }); + answersRef.current = nextAnswers; + setAnswers(nextAnswers); + applyProfilePatchResultToCache(queryClient, locale, result); + return undefined; }); flushPromiseRef.current = request; @@ -524,7 +547,9 @@ export function QuestionAnswersProvider({ return; } - if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey); + pendingFields.forEach((field) => { + dirtyKeysRef.current.delete(field.key); + }); if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); @@ -543,7 +568,7 @@ export function QuestionAnswersProvider({ [], ); }, - [mutateAsync, canEdit], + [mutateAsync, canEdit, locale, queryClient], ); const flushAnswersRef = useRef(flushAnswers); @@ -572,7 +597,7 @@ export function QuestionAnswersProvider({ const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), ); - const payload = { ...fullPayload, fields: pendingFields.slice(0, 1) }; + const payload = { ...fullPayload, fields: pendingFields }; const revision = answersRevisionRef.current; if (payload.fields.length === 0) { @@ -614,7 +639,9 @@ export function QuestionAnswersProvider({ } hasPendingSyncRef.current = false; - dirtyKeysRef.current.delete(payload.fields[0].key); + payload.fields.forEach((field) => { + dirtyKeysRef.current.delete(field.key); + }); const stillPending = dirtyKeysRef.current.size > 0; hasPendingSyncRef.current = stillPending; setHasPendingSync(stillPending); diff --git a/src/components/Componentes/question-answer.test.tsx b/src/components/Componentes/question-answer.test.tsx index 1a50e0b..0f99de7 100644 --- a/src/components/Componentes/question-answer.test.tsx +++ b/src/components/Componentes/question-answer.test.tsx @@ -1,11 +1,23 @@ -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage"; -import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data"; +import { + useMarriageSectionDataQuery, + useUpdateMarriageSectionDataMutation, +} from "@/hooks/marriage/use-section-data"; +import { convertSchemaToFrontendItems } from "@/lib/schema-adapter"; +import { + QuestionAnswersProvider, + useQuestionAnswers, +} from "./question-answer-storage"; vi.mock("@/hooks/marriage/use-form-schema", () => ({ useFormSchemaQuery: vi.fn(), @@ -14,6 +26,7 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({ useMarriageProfileQuery: vi.fn(), })); vi.mock("@/hooks/marriage/use-section-data", () => ({ + applyProfilePatchResultToCache: vi.fn(), useMarriageSectionDataQuery: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(), })); @@ -28,8 +41,22 @@ function TestComponent({ slug }: { slug: string }) { data-testid="set-radio" onClick={() => setAnswerValue( - { id: "q1", type: "radio", title: "Q1", order: 1, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] }, - "opt1" + { + id: "q1", + type: "radio", + title: "Q1", + order: 1, + required: true, + baseRequired: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "", options: [], range: [0, 0] }, + options: [ + { id: "opt1", value: "A", label: "Option A", order: 1 }, + ], + }, + "opt1", ) } > @@ -40,8 +67,23 @@ function TestComponent({ slug }: { slug: string }) { data-testid="set-checkbox" onClick={() => setAnswerValue( - { id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, baseRequired: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] }, - ["opt2", "opt3"] + { + id: "q2", + type: "checkbox", + title: "Q2", + order: 2, + required: true, + baseRequired: true, + isVisible: true, + description: "", + tooltip: "", + extras: { placeHolder: "", options: [], range: [0, 0] }, + options: [ + { id: "opt2", value: "B", label: "Option B", order: 1 }, + { id: "opt3", value: "C", label: "Option C", order: 2 }, + ], + }, + ["opt2", "opt3"], ) } > @@ -87,19 +129,35 @@ describe("Question Answer & Schema Integration", () => { }); it("should send option_id instead of label/value for radio", async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - + , ); fireEvent.click(screen.getByTestId("set-radio")); @@ -114,19 +172,36 @@ describe("Question Answer & Schema Integration", () => { }); it("should send array of option_ids for checkbox", async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - + , ); fireEvent.click(screen.getByTestId("set-checkbox")); @@ -139,6 +214,61 @@ describe("Question Answer & Schema Integration", () => { }); }); + it("batches all dirty answers into one mutation", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + + + , + ); + + fireEvent.click(screen.getByTestId("set-radio")); + fireEvent.click(screen.getByTestId("set-checkbox")); + fireEvent.click(screen.getByTestId("save")); + + await waitFor(() => expect(capturedPayload?.fields).toHaveLength(2)); + }); + it("should sort schema questions and options by order correctly", () => { const mockSchema = { sections: [ @@ -155,25 +285,52 @@ describe("Question Answer & Schema Integration", () => { title: "Card", order: 2, questions: [ - { id: "q1", title: "Q1", type: "text", order: 10, required: true, is_visible: true, ui_config: {}, options: [] }, - { id: "q2", title: "Q2", type: "text", order: 5, required: true, is_visible: true, ui_config: {}, options: [ - { id: "opt1", value: "A", label: "Option A", order: 2 }, - { id: "opt2", value: "B", label: "Option B", order: 1 } - ] }, - ] + { + id: "q1", + title: "Q1", + type: "text", + order: 10, + required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + { + id: "q2", + title: "Q2", + type: "text", + order: 5, + required: true, + is_visible: true, + ui_config: {}, + options: [ + { id: "opt1", value: "A", label: "Option A", order: 2 }, + { id: "opt2", value: "B", label: "Option B", order: 1 }, + ], + }, + ], }, { id: "card2", title: "Card 2", order: 1, questions: [ - { id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] }, - ] - } - ] - } + { + id: "q3", + title: "Q3", + type: "text", + order: 1, + required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + ], + }, + ], + }, ], - progress: { sections_progress: {} } + progress: { sections_progress: {} }, } as any; const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); @@ -193,14 +350,24 @@ describe("Question Answer & Schema Integration", () => { data: { slug: "test_slug", data: [ - { key: "q_radio", type: "radio", value: "Server Canonical Value", option_id: "opt_radio" }, - { key: "q_check", type: "checkbox", value: ["Server Val 1", "Server Val 2"], option_id: ["opt_check1", "opt_check2"] }, + { + key: "q_radio", + type: "radio", + value: "Server Canonical Value", + option_id: "opt_radio", + }, + { + key: "q_check", + type: "checkbox", + value: ["Server Val 1", "Server Val 2"], + option_id: ["opt_check1", "opt_check2"], + }, ], }, isLoading: false, }); - - let capturedValues: any = {}; + + const capturedValues: any = {}; function HydrationTestComponent() { const { getAnswerValue } = useQuestionAnswers(); @@ -209,14 +376,16 @@ describe("Question Answer & Schema Integration", () => { return
; } - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - + , ); // Give it a moment to reconcile useEffect in QuestionAnswersProvider @@ -242,13 +411,23 @@ describe("Question Answer & Schema Integration", () => { title: "Card", order: 1, questions: [ - { id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] }, - ] - } - ] - } + { + id: "q1", + title: "Q1", + type: "text", + order: 1, + required: false, + is_required: true, + is_visible: true, + ui_config: {}, + options: [], + }, + ], + }, + ], + }, ], - progress: { sections_progress: {} } + progress: { sections_progress: {} }, } as any; const frontendItems = convertSchemaToFrontendItems(mockSchema, "en"); diff --git a/src/components/Componentes/schema-question-flow.integration.test.tsx b/src/components/Componentes/schema-question-flow.integration.test.tsx index 9e543c6..4e4d72f 100644 --- a/src/components/Componentes/schema-question-flow.integration.test.tsx +++ b/src/components/Componentes/schema-question-flow.integration.test.tsx @@ -36,6 +36,7 @@ vi.mock("@/hooks/marriage/use-profile-main", () => ({ useMarriageProfileQuery: vi.fn(), })); vi.mock("@/hooks/marriage/use-section-data", () => ({ + applyProfilePatchResultToCache: vi.fn(), useMarriageSectionDataQuery: vi.fn(), useUpdateMarriageSectionDataMutation: vi.fn(), })); diff --git a/src/components/Componentes/token-switcher.tsx b/src/components/Componentes/token-switcher.tsx index f614661..8e0d65c 100644 --- a/src/components/Componentes/token-switcher.tsx +++ b/src/components/Componentes/token-switcher.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { MdOutlineSwitchAccount } from "react-icons/md"; +import { MdOutlineSwitchAccount, MdDragIndicator } from "react-icons/md"; import { IoClose, IoTrashOutline, IoKeyOutline } from "react-icons/io5"; import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; @@ -36,9 +36,16 @@ export function TokenSwitcher({ const [isOpen, setIsOpen] = useState(false); const [currentToken, setCurrentToken] = useState(""); const [customTokenInput, setCustomTokenInput] = useState(""); - const [isCustomInputOpen, setIsCustomInputOpen] = useState(false); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + const dragStart = useRef<{ + x: number; + y: number; + startX: number; + startY: number; + hasMoved: boolean; + }>({ x: 0, y: 0, startX: 12, startY: 12, hasMoved: false }); useEffect(() => { setMounted(true); @@ -256,42 +263,152 @@ export function TokenSwitcher({ }, ]; + // Pointer drag handlers + const handlePointerDown = (e: React.PointerEvent) => { + if (!isFloating || isOpen) return; + + e.currentTarget.setPointerCapture(e.pointerId); + setIsDragging(true); + const startX = position?.x ?? 12; + const startY = position?.y ?? 12; + dragStart.current = { + x: e.clientX, + y: e.clientY, + startX, + startY, + hasMoved: false, + }; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDragging || !isFloating) return; + + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + + if (Math.hypot(dx, dy) > 4) { + dragStart.current.hasMoved = true; + } + + const width = containerRef.current?.offsetWidth || 140; + const height = containerRef.current?.offsetHeight || 40; + + const newX = Math.max( + 10, + Math.min(window.innerWidth - width - 10, dragStart.current.startX + dx) + ); + const newY = Math.max( + 10, + Math.min(window.innerHeight - height - 10, dragStart.current.startY + dy) + ); + + setPosition({ x: newX, y: newY }); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!isDragging || !isFloating) return; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch (err) {} + setIsDragging(false); + + if (dragStart.current.hasMoved) { + if (position) { + try { + localStorage.setItem(STORAGE_POS_KEY, JSON.stringify(position)); + } catch (e) {} + } + } + }; + + const handleBadgeClick = (e: React.MouseEvent) => { + if (dragStart.current.hasMoved) { + e.preventDefault(); + e.stopPropagation(); + return; + } + setIsOpen(true); + }; + + const currentRoleLabel = isMale + ? "آقا 👨" + : isMale2 + ? "آقا ۲ 👨" + : isFemale + ? "خانم 👩" + : isGuestToken + ? (guestAccounts.find((g) => g.token === currentToken)?.title ?? "مهمان 👤") + : isNoToken + ? "بدون توکن 👤" + : "سفارشی 🔑"; + + if (!mounted && isFloating) { + return null; + } + + const badgeContent = ( +
setIsDragging(false)} + onClick={handleBadgeClick} + style={ + isFloating + ? { + position: "fixed", + left: position ? `${position.x}px` : "12px", + top: position ? `${position.y}px` : "12px", + zIndex: 99999, + touchAction: "none", + userSelect: "none", + } + : undefined + } + className={[ + isFloating + ? "fixed flex items-center gap-1.5 px-3 py-2 rounded-[15px] backdrop-blur-md shadow-md border select-none transition-shadow cursor-pointer" + : "inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 shadow-md border backdrop-blur-md cursor-pointer", + variant === "transparent" + ? "bg-black/60 text-white border-white/20 hover:bg-black/70" + : "bg-white/95 text-slate-800 border-slate-200/80 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-700/80", + isDragging + ? "cursor-grabbing shadow-2xl ring-2 ring-rose-500/50 scale-[1.02]" + : "cursor-grab active:scale-95", + className, + ] + .filter(Boolean) + .join(" ")} + title="تغییر توکن کاربر (تست) - برای جابجایی بکشید / برای انتخاب کلیک کنید" + > + {/* Drag handle */} + {isFloating && ( +
+ +
+ )} + + {/* Role icon & selected account label */} + + {currentRoleLabel} +
+ ); + return ( <> - + {badgeContent} {isOpen && ( -
+
setIsOpen(false)} + >
e.stopPropagation()} > {/* Modal Header */} diff --git a/src/hooks/marriage/types.ts b/src/hooks/marriage/types.ts index 1e6b348..1c331d4 100644 --- a/src/hooks/marriage/types.ts +++ b/src/hooks/marriage/types.ts @@ -157,6 +157,34 @@ export type UpdateMarriageSectionDataPayload = { fields: MarriageField[]; }; +export type MarriageQuestionState = { + is_visible: boolean; + is_required: boolean; +}; + +export type ProfileAnswersPatchResult = { + version: number; + answers: Record< + string, + { value: MarriageFieldValue; option_id?: string | string[] } + >; + cleared_answer_ids?: string[]; + question_states?: Record; + affected_sections: Record< + string, + { current_step: number; total_steps: number; completion_percent: number } + >; + progress: { + current_step: number; + total_steps: number; + completion_percent: number; + sections_progress: Record< + string, + { current_step: number; total_steps: number; completion_percent: number } + >; + }; +}; + export type StartMarriageMatchResponse = { detail: string; }; diff --git a/src/hooks/marriage/use-section-data.test.ts b/src/hooks/marriage/use-section-data.test.ts new file mode 100644 index 0000000..9a36108 --- /dev/null +++ b/src/hooks/marriage/use-section-data.test.ts @@ -0,0 +1,99 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it } from "vitest"; +import { marriageQueryKeys } from "./query-keys"; +import type { ProfileAnswersPatchResult } from "./types"; +import { applyProfilePatchResultToCache } from "./use-section-data"; + +describe("applyProfilePatchResultToCache", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + window.localStorage.clear(); + }); + + it("applies cleared answers, question states, and cross-section progress", () => { + const sectionAKey = marriageQueryKeys.formSection( + "profile", + "section-a", + "en", + ); + const sectionBKey = marriageQueryKeys.formSection( + "profile", + "section-b", + "en", + ); + queryClient.setQueryData(sectionAKey, { + answers: { parent: { value: "yes" } }, + section: { cards: [{ questions: [{ id: "parent", is_visible: true }] }] }, + }); + queryClient.setQueryData(sectionBKey, { + answers: { child: { value: "old" } }, + section: { + cards: [ + { + questions: [{ id: "child", is_visible: true, is_required: true }], + }, + ], + }, + }); + queryClient.setQueryData(marriageQueryKeys.formOverview("profile", "en"), { + sections: [ + { id: "section-a", progress: {} }, + { id: "section-b", progress: {} }, + ], + }); + window.localStorage.setItem( + "marriage:sections:section-b:answers:v2", + JSON.stringify({ + fields: [{ key: "child", value: "old" }], + pending_keys: ["child"], + pending_sync: true, + }), + ); + + const result: ProfileAnswersPatchResult = { + version: 38, + answers: { parent: { value: "no" } }, + cleared_answer_ids: ["child"], + question_states: { + child: { is_visible: false, is_required: false }, + }, + affected_sections: { + "section-a": { + current_step: 1, + total_steps: 1, + completion_percent: 100, + }, + "section-b": { + current_step: 0, + total_steps: 0, + completion_percent: 100, + }, + }, + progress: { + current_step: 1, + total_steps: 1, + completion_percent: 100, + sections_progress: {}, + }, + }; + + applyProfilePatchResultToCache(queryClient, "en", result); + + const sectionB = queryClient.getQueryData(sectionBKey); + expect(sectionB.answers.child).toBeUndefined(); + expect(sectionB.section.cards[0].questions[0]).toMatchObject({ + is_visible: false, + is_required: false, + }); + expect(sectionB.section_progress.current_step).toBe(0); + expect( + window.localStorage.getItem("marriage:sections:section-b:answers:v2"), + ).toBeNull(); + const overview = queryClient.getQueryData( + marriageQueryKeys.formOverview("profile", "en"), + ); + expect(overview.sections[1].progress.total_steps).toBe(0); + }); +}); diff --git a/src/hooks/marriage/use-section-data.ts b/src/hooks/marriage/use-section-data.ts index 298f5a9..db37e72 100644 --- a/src/hooks/marriage/use-section-data.ts +++ b/src/hooks/marriage/use-section-data.ts @@ -1,40 +1,37 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + type QueryClient, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import { http } from "@/lib/http"; import type { MutationOptions } from "./options"; import { marriageQueryKeys } from "./query-keys"; import type { MarriageSectionData, + ProfileAnswersPatchResult, UpdateMarriageSectionDataPayload, } from "./types"; -import { - type FormSchemaResponse, - useFormSectionQuery, -} from "./use-form-schema"; +import { useFormSectionQuery } from "./use-form-schema"; export async function updateMarriageSectionData( slug: string, payload: UpdateMarriageSectionDataPayload, -) { +): Promise { const answersPayload = payload.fields.map((f) => ({ question_id: f.key, value: f.value, - option_id: (f as any).option_id || undefined, + option_id: f.option_id || undefined, })); - const { data } = await http.patch<{ - version: number; - answers: FormSchemaResponse["answers"]; - affected_sections: Record< - string, - { current_step: number; total_steps: number; completion_percent: number } - >; - progress: FormSchemaResponse["progress"]; - }>(`/api/marriage/forms/profile/answers/`, { - version: payload.version, - answers: answersPayload, - }); + const { data } = await http.patch( + `/api/marriage/forms/profile/answers/`, + { + version: payload.version, + answers: answersPayload, + }, + ); const prog = data.affected_sections[slug] || data.progress.sections_progress[slug] || { @@ -47,6 +44,9 @@ export async function updateMarriageSectionData( version: data.version, progress: data.progress, answers: data.answers, + cleared_answer_ids: data.cleared_answer_ids, + question_states: data.question_states, + affected_sections: data.affected_sections, sectionData: { slug, data: payload.fields, @@ -58,6 +58,105 @@ export async function updateMarriageSectionData( }; } +export function applyProfilePatchResultToCache( + queryClient: QueryClient, + locale: string, + data: ProfileAnswersPatchResult, +) { + const cleared = new Set(data.cleared_answer_ids ?? []); + if (typeof window !== "undefined" && cleared.size > 0) { + for (let index = 0; index < window.localStorage.length; index += 1) { + const storageKey = window.localStorage.key(index); + if (!storageKey?.startsWith("marriage:sections:")) continue; + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) continue; + try { + const stored = JSON.parse(rawValue); + if (!Array.isArray(stored.fields)) continue; + const fields = stored.fields.filter( + (field: { key?: string }) => !field.key || !cleared.has(field.key), + ); + if (fields.length === stored.fields.length) continue; + if (fields.length === 0) { + window.localStorage.removeItem(storageKey); + index -= 1; + continue; + } + stored.fields = fields; + stored.pending_keys = Array.isArray(stored.pending_keys) + ? stored.pending_keys.filter((key: string) => !cleared.has(key)) + : []; + stored.pending_sync = stored.pending_keys.length > 0; + window.localStorage.setItem(storageKey, JSON.stringify(stored)); + } catch { + // Leave malformed drafts untouched; normal hydration handles them. + } + } + } + const patchSection = (current: any, slug: string) => { + if (!current) return current; + const sectionProgress = + data.affected_sections[slug] ?? data.progress.sections_progress[slug]; + const answers = { ...(current.answers ?? {}) }; + Object.entries(data.answers ?? {}).forEach(([key, answer]) => { + answers[key] = answer; + }); + cleared.forEach((key) => { + delete answers[key]; + }); + const questionStates = data.question_states ?? {}; + const section = current.section + ? { + ...current.section, + cards: current.section.cards?.map((card: any) => ({ + ...card, + questions: card.questions?.map((question: any) => + questionStates[question.id] + ? { ...question, ...questionStates[question.id] } + : question, + ), + })), + } + : current.section; + return { + ...current, + version: data.version, + answers, + section, + progress: data.progress, + ...(sectionProgress ? { section_progress: sectionProgress } : {}), + }; + }; + + queryClient + .getQueriesData({ + queryKey: marriageQueryKeys + .formSection("profile", "", locale) + .slice(0, 3), + }) + .forEach(([queryKey, current]) => { + const slug = String(queryKey[3] ?? ""); + queryClient.setQueryData(queryKey, patchSection(current, slug)); + }); + queryClient.setQueryData( + marriageQueryKeys.formOverview("profile", locale), + (current: any) => + current + ? { + ...current, + version: data.version, + progress: data.progress, + sections: current.sections.map((section: any) => { + const progress = + data.affected_sections[section.id] ?? + data.progress.sections_progress[section.id]; + return progress ? { ...section, progress } : section; + }), + } + : current, + ); +} + export function useMarriageSectionDataQuery( slug: string | null | undefined, locale: string, @@ -116,35 +215,7 @@ export function useUpdateMarriageSectionDataMutation( ...options, mutationFn: (payload) => updateMarriageSectionData(slug, payload), onSuccess: async (data, variables, onMutateResult, context) => { - queryClient.setQueryData( - marriageQueryKeys.formSection("profile", slug, locale), - (current: any) => - current - ? { - ...current, - version: data.version, - progress: data.progress, - section_progress: data.sectionData, - answers: { ...current.answers, ...data.answers }, - } - : current, - ); - queryClient.setQueryData( - marriageQueryKeys.formOverview("profile", locale), - (current: any) => - current - ? { - ...current, - version: data.version, - progress: data.progress, - sections: current.sections.map((section: any) => - section.id === slug - ? { ...section, progress: data.sectionData } - : section, - ), - } - : current, - ); + applyProfilePatchResultToCache(queryClient, locale, data); await options?.onSuccess?.(data, variables, onMutateResult, context); }, });