"use client"; import { useQueryClient } from "@tanstack/react-query"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { pathParam } from "@/hooks/marriage/path-param"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import type { MarriageField, MarriageFieldValue, MarriagePhoneFieldValue, UpdateMarriageSectionDataPayload, } 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 { authBridge } from "@/lib/auth-bridge"; import type { QuestionField } from "@/lib/schema-adapter"; import { getScopedSectionDraftKey, readScopedSectionDraft, removeScopedSectionDraft, writeScopedSectionDraft, type ScopedPendingField, } from "@/lib/user-scoped-storage"; const STORAGE_VERSION = 2; type QuestionAnswersByKey = Record; type StoredQuestionAnswers = { version: typeof STORAGE_VERSION; slug: string; current_step: number; fields: MarriageField[]; pending_sync: boolean; pending_keys: string[]; updated_at: string; }; type FlushAnswersOptions = { force?: boolean; }; type QuestionAnswersContextValue = { flushAnswers: (options?: FlushAnswersOptions) => Promise; getAnswerValue: (question: QuestionField) => MarriageFieldValue | undefined; hasPendingSync: boolean; isSaving: boolean; isLoading: boolean; setAnswerValue: (question: QuestionField, value: MarriageFieldValue) => void; backendFields: MarriageField[]; answers: QuestionAnswersByKey; }; type QuestionAnswersProviderProps = { children: ReactNode; questions: readonly QuestionField[]; slug: string; locale?: string; }; const QuestionAnswersContext = createContext(null); export function getQuestionAnswersStorageKey(slug: string) { return `marriage:sections:${slug}:answers:v${STORAGE_VERSION}`; } export function hasQuestionAnswerValue(value: MarriageFieldValue) { if (value === null) { return false; } if (typeof value === "string") { return value.trim().length > 0; } return true; } function isMarriageField(value: unknown): value is MarriageField { if (!value || typeof value !== "object") { return false; } const field = value as Partial; return ( typeof field.key === "string" && typeof field.label === "string" && typeof field.type === "string" && (field.value === null || typeof field.value === "string" || typeof field.value === "number" || typeof field.value === "boolean" || Array.isArray(field.value) || isMarriagePhoneFieldValue(field.value)) ); } function isMarriagePhoneFieldValue( value: unknown, ): value is MarriagePhoneFieldValue { if (!value || typeof value !== "object") { return false; } const phoneValue = value as Partial; return ( typeof phoneValue.countryCode === "string" && typeof phoneValue.phoneNumber === "string" ); } function createQuestionField( question: QuestionField, value: MarriageFieldValue, ): MarriageField { let option_id: string | string[] | undefined; if (question.options && Array.isArray(question.options) && question.options.length > 0) { if (Array.isArray(value)) { option_id = value; } else if (typeof value === "string" && value) { const selectedOpt = question.options.find((opt) => opt.id === value || opt.value === value); option_id = selectedOpt ? selectedOpt.id : value; } } const key = question.id; return { key, label: question.title, type: question.type, value, private: question.private, option_id: option_id, } as MarriageField; } function getOrderedFields( answers: QuestionAnswersByKey, questions: readonly QuestionField[], backendFields?: MarriageField[], ) { const orderedFields: MarriageField[] = []; const orderedKeys = new Set(); questions.forEach((question) => { const key = question.id; const field = answers[key]; if (field) { orderedFields.push(field); orderedKeys.add(key); } }); Object.entries(answers).forEach(([key, field]) => { if (!orderedKeys.has(key)) { orderedFields.push(field); } }); return orderedFields; } function getCurrentStep( fields: MarriageField[], questions: readonly QuestionField[], backendFields?: MarriageField[], ) { return questions.filter((question) => { if (!question.required || !question.isVisible) { return false; } const key = question.id; const field = fields.find((f) => f.key === key); return field && hasQuestionAnswerValue(field.value); }).length; } function createPayload( answers: QuestionAnswersByKey, questions: readonly QuestionField[], backendFields?: MarriageField[], ): UpdateMarriageSectionDataPayload { const fields = getOrderedFields(answers, questions, backendFields); const targetQuestions = questions.filter((q) => q.required && q.isVisible); return { current_step: getCurrentStep(fields, questions, backendFields), total_steps: targetQuestions.length, fields, }; } function fieldsToAnswers(fields: MarriageField[]) { return fields.reduce((nextAnswers, field) => { if (field.option_id !== undefined && field.option_id !== null) { nextAnswers[field.key] = { ...field, value: field.option_id, }; } else { nextAnswers[field.key] = field; } return nextAnswers; }, {}); } function readStoredAnswers(storageKey: string, slug: string) { try { const rawValue = window.localStorage.getItem(storageKey); if (!rawValue) { return { answers: {}, pendingSync: false, pendingKeys: [], }; } const storedValue = JSON.parse(rawValue) as Partial; const fields = Array.isArray(storedValue.fields) ? storedValue.fields.filter(isMarriageField) : []; return { answers: fieldsToAnswers(fields), pendingSync: storedValue.slug === slug && (storedValue.pending_sync ?? fields.length > 0), pendingKeys: Array.isArray(storedValue.pending_keys) ? storedValue.pending_keys.filter( (key): key is string => typeof key === "string", ) : fields.map((field) => field.key), }; } catch { return { answers: {}, pendingSync: false, pendingKeys: [], }; } } function writeStoredAnswers( storageKey: string, slug: string, questions: readonly QuestionField[], answers: QuestionAnswersByKey, pendingSync: boolean, backendFields?: MarriageField[], pendingKeys: readonly string[] = [], ) { try { const payload = createPayload(answers, questions, backendFields); if (payload.fields.length === 0) { window.localStorage.removeItem(storageKey); return; } const storedValue: StoredQuestionAnswers = { version: STORAGE_VERSION, slug, current_step: payload.current_step, fields: payload.fields, pending_sync: pendingSync, pending_keys: pendingSync ? [...pendingKeys] : [], updated_at: new Date().toISOString(), }; window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); } catch { // localStorage can fail in private mode or when storage quota is exhausted. } } function getKeepalivePatchUrl(slug: string) { return getApiRequestUrl(`/api/marriage/forms/profile/answers/`); } function getCsrfToken() { if (typeof document === "undefined") return ""; const match = document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/); return match ? match[2] : ""; } export function QuestionAnswersProvider({ children, questions, slug, locale = "en", }: QuestionAnswersProviderProps) { const storageKey = useMemo(() => getQuestionAnswersStorageKey(slug), [slug]); const queryClient = useQueryClient(); const [answers, setAnswers] = useState({}); const [hasPendingSync, setHasPendingSync] = useState(false); const { isPending: isSaving, mutateAsync } = useUpdateMarriageSectionDataMutation(slug, locale); const answersRef = useRef({}); const hasPendingSyncRef = useRef(false); const answersRevisionRef = useRef(0); const flushPromiseRef = useRef | null>(null); const questionsRef = useRef(questions); const storageKeyRef = useRef(storageKey); const slugRef = useRef(slug); const backendFieldsRef = useRef([]); const versionRef = useRef(undefined); const dirtyKeysRef = useRef(new Set()); const { data: profile } = useMarriageProfileQuery(); const canEdit = profile?.can_edit_profile !== false; const { data: serverSectionData, isLoading: isLoadingData } = useMarriageSectionDataQuery(slug, locale); useEffect(() => { answersRef.current = answers; hasPendingSyncRef.current = hasPendingSync; slugRef.current = slug; questionsRef.current = questions; backendFieldsRef.current = serverSectionData?.data || []; versionRef.current = serverSectionData?.version; }, [ answers, hasPendingSync, slug, questions, serverSectionData?.data, serverSectionData?.version, ]); useEffect(() => { storageKeyRef.current = storageKey; slugRef.current = slug; const profileId = profile?.id; // ── NEW RECONCILIATION (V3): Server is canonical ────────────── // // displayAnswers = canonicalServerAnswers // THEN overlay ONLY local fields explicitly listed as pending/dirty // for the CURRENT user. // // A single pending question must never make every historical local // answer override server data. A successful server response containing // no answers must render an empty section unless the current user has // genuine unsynced pending fields. if (serverSectionData?.data) { // Step 1: Server is canonical const serverAnswers = fieldsToAnswers(serverSectionData.data); let finalAnswers: QuestionAnswersByKey = { ...serverAnswers }; let finalPendingSync = false; const nextDirtyKeys = new Set(); // Preserve active in-memory dirty edits so a server query update doesn't overwrite unsynced keystrokes for (const dirtyKey of dirtyKeysRef.current) { if (answersRef.current[dirtyKey]) { finalAnswers[dirtyKey] = answersRef.current[dirtyKey]; nextDirtyKeys.add(dirtyKey); finalPendingSync = true; } } // Step 2: If we have a valid profile, read the user-scoped draft if (profileId && canEdit) { const scopedDraft = readScopedSectionDraft(profileId, slug); if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) { // Overlay ONLY the pending dirty fields from the current user for (const [key, pendingField] of Object.entries(scopedDraft.pending)) { finalAnswers[key] = { key: pendingField.key, label: pendingField.label, type: pendingField.type, value: pendingField.value, option_id: pendingField.option_id ?? undefined, private: pendingField.private, } as MarriageField; nextDirtyKeys.add(key); } finalPendingSync = true; } } // Also check the old V2 storage for backward compatibility during // the transition period. Any old V2 pending edits are treated as // a one-time overlay but NOT migrated (ownership can't be proven). // After this render they won't be re-read because the V2 key is // cleaned up by the legacy migration in AuthDataBoundary. if (nextDirtyKeys.size === 0) { const legacyStored = readStoredAnswers(storageKey, slug); if (legacyStored.pendingSync && legacyStored.pendingKeys.length > 0 && canEdit) { for (const pendingKey of legacyStored.pendingKeys) { const pendingField = legacyStored.answers[pendingKey]; if (pendingField) { finalAnswers[pendingKey] = pendingField; nextDirtyKeys.add(pendingKey); } } if (nextDirtyKeys.size > 0) { finalPendingSync = true; // Migrate these to scoped storage if we have a profile if (profileId) { const pendingMap: Record = {}; for (const key of nextDirtyKeys) { const field = finalAnswers[key]; if (field) { pendingMap[key] = { key: field.key, label: field.label, type: field.type, value: field.value, option_id: (field as any).option_id, private: field.private, }; } } writeScopedSectionDraft(profileId, slug, pendingMap); } // Remove old V2 key try { window.localStorage.removeItem(storageKey); } catch {} } } } dirtyKeysRef.current = nextDirtyKeys; answersRef.current = finalAnswers; hasPendingSyncRef.current = finalPendingSync; setAnswers((prev) => { const prevKeys = Object.keys(prev); const nextKeys = Object.keys(finalAnswers); if (prevKeys.length === nextKeys.length) { const isSame = prevKeys.every( (k) => prev[k]?.value === finalAnswers[k]?.value, ); if (isSame) return prev; } return finalAnswers; }); setHasPendingSync(finalPendingSync); } }, [slug, storageKey, serverSectionData, questions, canEdit, profile?.id]); const syncTimeoutRef = useRef(null); useEffect(() => { return () => { if (syncTimeoutRef.current !== null) { clearTimeout(syncTimeoutRef.current); } }; }, []); const getAnswerValue = useCallback( (question: QuestionField) => { const key = question.id; return answers[key]?.value; }, [answers], ); const setAnswerValue = useCallback( (question: QuestionField, value: MarriageFieldValue) => { if (!canEdit) { return; } const field = createQuestionField(question, value); setAnswers((currentAnswers) => { const nextAnswers = { ...currentAnswers, [field.key]: field, }; answersRef.current = nextAnswers; hasPendingSyncRef.current = true; answersRevisionRef.current += 1; dirtyKeysRef.current.add(field.key); // Write only dirty fields to profile-scoped localStorage const currentProfileId = profile?.id; if (currentProfileId) { const pendingMap: Record = {}; for (const dirtyKey of dirtyKeysRef.current) { const dirtyField = nextAnswers[dirtyKey]; if (dirtyField) { pendingMap[dirtyKey] = { key: dirtyField.key, label: dirtyField.label, type: dirtyField.type, value: dirtyField.value, option_id: (dirtyField as any).option_id, private: dirtyField.private, }; } } writeScopedSectionDraft(currentProfileId, slugRef.current, pendingMap); } if (syncTimeoutRef.current !== null) { clearTimeout(syncTimeoutRef.current); } const isTextLike = question.type === "text" || question.type === "textarea" || question.type === "number"; // The date wheel applies its value on every scroll settle; debounce // it like text inputs so each spin does not fire a PATCH. const isDebounced = isTextLike || question.type === "date"; const delay = isDebounced ? 1000 : 0; syncTimeoutRef.current = setTimeout(() => { void flushAnswersRef.current().catch((err) => { console.error("[FLUSH] Auto-flush FAILED:", err); // The draft stays pending so the next edit or exit can retry. }); }, delay); return nextAnswers; }); setHasPendingSync(true); }, [canEdit, serverSectionData?.data], ); const flushAnswers = useCallback( async (options?: FlushAnswersOptions) => { if (!canEdit) { return; } if (flushPromiseRef.current) { await flushPromiseRef.current; } if (!hasPendingSyncRef.current && !options?.force) { return; } const fullPayload = createPayload( answersRef.current, questionsRef.current, backendFieldsRef.current, ); const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), ); const payload = { ...fullPayload, fields: pendingFields, version: versionRef.current, }; const revision = answersRevisionRef.current; if (payload.fields.length === 0) { return; } const request = mutateAsync(payload).then((result) => { 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) { // Only update if there are no newer local dirty edits for this key if (!dirtyKeysRef.current.has(key)) { 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; try { await request; } finally { if (flushPromiseRef.current === request) { flushPromiseRef.current = null; } } 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) { if (options?.force) { await flushAnswersRef.current(); } return; } if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); return; } hasPendingSyncRef.current = false; setHasPendingSync(false); // All dirty fields acknowledged — remove the draft entirely const currentProfileId = profile?.id; if (currentProfileId) { removeScopedSectionDraft(currentProfileId, slugRef.current); } }, [mutateAsync, canEdit, locale, queryClient, profile?.id], ); const flushAnswersRef = useRef(flushAnswers); useEffect(() => { flushAnswersRef.current = flushAnswers; }, [flushAnswers]); useEffect(() => { const flushWithKeepalive = () => { if (!canEdit) { return; } if (!hasPendingSyncRef.current) { return; } if (flushPromiseRef.current) { return; } const fullPayload = createPayload( answersRef.current, questionsRef.current, backendFieldsRef.current, ); const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), ); const payload = { ...fullPayload, fields: pendingFields, version: versionRef.current }; const revision = answersRevisionRef.current; if (payload.fields.length === 0) { return; } const headers: Record = { Accept: "application/json", "Content-Type": "application/json", }; const csrfToken = getCsrfToken(); if (csrfToken) { headers["X-CSRFToken"] = csrfToken; } // Include auth token so the proxy / backend can authenticate the keepalive request const token = authBridge.getToken(); if (token) { headers["Authorization"] = `Token ${token}`; } const answersPayload = payload.fields.map((f) => ({ question_id: f.key, value: f.value, option_id: (f as any).option_id ?? undefined, })); fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify({ answers: answersPayload, version: payload.version, }), credentials: "include", headers, keepalive: true, method: "PATCH", }) .then(async (response) => { if (!response.ok) { const text = await response.text().catch(() => ''); console.error('[KEEPALIVE] Response NOT OK:', response.status, response.statusText, text.slice(0, 500)); return; } if (revision !== answersRevisionRef.current) { return; } hasPendingSyncRef.current = false; payload.fields.forEach((field) => { dirtyKeysRef.current.delete(field.key); }); const stillPending = dirtyKeysRef.current.size > 0; hasPendingSyncRef.current = stillPending; setHasPendingSync(stillPending); // Update scoped draft: remove acknowledged fields or delete draft const currentProfileId = profile?.id; if (currentProfileId) { if (stillPending) { const remainingPending: Record = {}; for (const dirtyKey of dirtyKeysRef.current) { const field = answersRef.current[dirtyKey]; if (field) { remainingPending[dirtyKey] = { key: field.key, label: field.label, type: field.type, value: field.value, option_id: (field as any).option_id, private: field.private, }; } } writeScopedSectionDraft(currentProfileId, slugRef.current, remainingPending); } else { removeScopedSectionDraft(currentProfileId, slugRef.current); } } void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.profile(), }); void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.sections(), }); void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.sectionData(slugRef.current), }); }) .catch((err) => { console.error('[KEEPALIVE] Fetch FAILED:', err); // The local draft stays marked pending so a later exit can retry. }); }; window.addEventListener("pagehide", flushWithKeepalive); return () => { window.removeEventListener("pagehide", flushWithKeepalive); flushWithKeepalive(); }; }, [canEdit, queryClient.invalidateQueries]); const contextValue = useMemo( () => ({ flushAnswers, getAnswerValue, hasPendingSync, isSaving, isLoading: isLoadingData, setAnswerValue, backendFields: serverSectionData?.data || [], answers, }), [ flushAnswers, getAnswerValue, hasPendingSync, isSaving, isLoadingData, setAnswerValue, serverSectionData?.data, answers, ], ); return ( {children} ); } export function useQuestionAnswers() { const context = useContext(QuestionAnswersContext); if (!context) { throw new Error( "useQuestionAnswers must be used inside QuestionAnswersProvider", ); } return context; } export function useQuestionAnswer(question: QuestionField) { const context = useContext(QuestionAnswersContext); return { setValue: (value: MarriageFieldValue) => { context?.setAnswerValue(question, value); }, value: context?.getAnswerValue(question), }; }