"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 type { QuestionField } from "@/lib/schema-adapter"; 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[]; }; 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)) { if (question.type === "checkbox" && Array.isArray(value)) { option_id = value; } else { const selectedOpt = question.options.find((opt) => opt.id === value); if (selectedOpt) { option_id = selectedOpt.id; } } } 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 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 || []; }, [ answers, hasPendingSync, slug, questions, serverSectionData?.data, ]); useEffect(() => { storageKeyRef.current = storageKey; slugRef.current = slug; const stored = readStoredAnswers(storageKey, slug); let finalAnswers = stored.answers; let finalPendingSync = stored.pendingSync; dirtyKeysRef.current = new Set(stored.pendingKeys); if (serverSectionData?.data) { const serverAnswers = fieldsToAnswers(serverSectionData.data); if (stored.pendingSync && canEdit) { // Merge: local answers override server answers for unsynced changes finalAnswers = { ...serverAnswers, ...stored.answers }; } else { // A section response can temporarily omit fields (most notably while // the combined family section is being refreshed). Keep locally known // fields that the response did not include, while letting explicit // server values, including null/empty values, win for matching keys. finalAnswers = { ...stored.answers, ...serverAnswers }; finalPendingSync = false; } } 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); // Update localStorage to stay in sync writeStoredAnswers( getQuestionAnswersStorageKey(slug), slug, questions, finalAnswers, finalPendingSync, serverSectionData?.data || undefined, [...dirtyKeysRef.current], ); }, [slug, storageKey, serverSectionData, questions, canEdit]); 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); writeStoredAnswers( storageKeyRef.current, slugRef.current, questionsRef.current, nextAnswers, true, undefined, [...dirtyKeysRef.current], ); 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(() => { // 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 nextDirtyKey = fullPayload.fields.find((field) => dirtyKeysRef.current.has(field.key), )?.key; const payload = { ...fullPayload, fields: nextDirtyKey ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) : [], }; 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) { 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; } } // 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 (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey); if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); return; } hasPendingSyncRef.current = false; setHasPendingSync(false); writeStoredAnswers( storageKeyRef.current, slugRef.current, questionsRef.current, answersRef.current, false, undefined, [], ); }, [mutateAsync, canEdit, locale, queryClient], ); 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 }; 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; } 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, }), credentials: "include", headers, keepalive: true, method: "PATCH", }) .then((response) => { if (!response.ok) { 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); writeStoredAnswers( storageKeyRef.current, slugRef.current, questionsRef.current, answersRef.current, stillPending, undefined, [...dirtyKeysRef.current], ); void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.profile(), }); void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.sections(), }); void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.sectionData(slugRef.current), }); }) .catch(() => { // 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 || [], }), [ flushAnswers, getAnswerValue, hasPendingSync, isSaving, isLoadingData, setAnswerValue, serverSectionData?.data, ], ); 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), }; }