"use client"; import { useQueryClient } from "@tanstack/react-query"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import type { QuestionField } from "@/data/question-data"; import { toBackendSlug } from "@/data/section-slug-map"; 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 { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation, } from "@/hooks/marriage/use-section-data"; import { getApiRequestUrl } from "@/lib/http"; const STORAGE_VERSION = 1; type QuestionAnswersByKey = Record; type StoredQuestionAnswers = { version: typeof STORAGE_VERSION; slug: string; current_step: number; fields: MarriageField[]; pending_sync: boolean; updated_at: string; }; type FlushAnswersOptions = { force?: boolean; }; type QuestionAnswersContextValue = { flushAnswers: (options?: FlushAnswersOptions) => Promise; getAnswerValue: ( question: QuestionField, questionIndex: number, ) => MarriageFieldValue | undefined; hasPendingSync: boolean; isSaving: boolean; isLoading: boolean; setAnswerValue: ( question: QuestionField, questionIndex: number, value: MarriageFieldValue, ) => void; }; type QuestionAnswersProviderProps = { children: ReactNode; questions: readonly QuestionField[]; slug: string; }; const QuestionAnswersContext = createContext(null); function hashString(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } return hash.toString(36); } function slugifyQuestionTitle(title: string) { const slug = title .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); return slug || `field_${hashString(title)}`; } function getQuestionFieldKey(question: QuestionField, questionIndex: number) { const index = question.originalIndex !== undefined ? question.originalIndex : questionIndex; return `q${index + 1}_${slugifyQuestionTitle(question.englishTitle || question.title)}`; } export function getQuestionAnswersStorageKey(slug: string) { return `marriage:sections:${slug}:answers`; } 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, questionIndex: number, value: MarriageFieldValue, ): MarriageField { return { key: getQuestionFieldKey(question, questionIndex), label: question.title, type: question.type, value, private: question.private, }; } function getOrderedFields( answers: QuestionAnswersByKey, questions: readonly QuestionField[], ) { const orderedFields: MarriageField[] = []; const orderedKeys = new Set(); questions.forEach((question, index) => { const key = getQuestionFieldKey(question, index); 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[], ) { return questions.filter((question, index) => { if (!question.required || question.logic?.dependsOn) { return false; } const key = getQuestionFieldKey(question, index); const field = fields.find((f) => f.key === key); return field && hasQuestionAnswerValue(field.value); }).length; } function createPayload( answers: QuestionAnswersByKey, questions: readonly QuestionField[], ): UpdateMarriageSectionDataPayload { const fields = getOrderedFields(answers, questions); const targetQuestions = questions.filter( (q) => q.required && !q.logic?.dependsOn, ); return { current_step: getCurrentStep(fields, questions), total_steps: targetQuestions.length, fields, }; } function fieldsToAnswers(fields: MarriageField[]) { return fields.reduce((nextAnswers, field) => { 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, }; } 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), }; } catch { return { answers: {}, pendingSync: false, }; } } function writeStoredAnswers( storageKey: string, slug: string, questions: readonly QuestionField[], answers: QuestionAnswersByKey, pendingSync: boolean, ) { try { const payload = createPayload(answers, questions); 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, 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) { const backendSlug = toBackendSlug(slug); return getApiRequestUrl( `/api/marriage/sections/${pathParam(backendSlug)}/data/`, ); } 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, }: 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); 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 { data: profile } = useMarriageProfileQuery(); const canEdit = profile?.can_edit_profile !== false; const { data: serverSectionData, isLoading: isLoadingData } = useMarriageSectionDataQuery(slug); useEffect(() => { questionsRef.current = questions; }, [questions]); useEffect(() => { storageKeyRef.current = storageKey; slugRef.current = slug; const stored = readStoredAnswers(storageKey, slug); let finalAnswers = stored.answers; let finalPendingSync = stored.pendingSync; 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(finalAnswers); setHasPendingSync(finalPendingSync); // Update localStorage to stay in sync writeStoredAnswers( storageKey, slug, questions, finalAnswers, finalPendingSync, ); }, [slug, storageKey, serverSectionData, questions, canEdit]); const syncTimeoutRef = useRef(null); useEffect(() => { return () => { if (syncTimeoutRef.current !== null) { clearTimeout(syncTimeoutRef.current); } }; }, []); const getAnswerValue = useCallback( (question: QuestionField, questionIndex: number) => answers[getQuestionFieldKey(question, questionIndex)]?.value, [answers], ); const setAnswerValue = useCallback( ( question: QuestionField, questionIndex: number, value: MarriageFieldValue, ) => { if (!canEdit) { return; } const field = createQuestionField(question, questionIndex, value); setAnswers((currentAnswers) => { const nextAnswers = { ...currentAnswers, [field.key]: field, }; answersRef.current = nextAnswers; hasPendingSyncRef.current = true; answersRevisionRef.current += 1; writeStoredAnswers( storageKeyRef.current, slugRef.current, questionsRef.current, nextAnswers, true, ); if (syncTimeoutRef.current !== null) { clearTimeout(syncTimeoutRef.current); } const isTextLike = question.type === "text" || question.type === "textarea" || question.type === "number"; const delay = isTextLike ? 1000 : 0; syncTimeoutRef.current = setTimeout(() => { void flushAnswersRef.current(); }, delay); return nextAnswers; }); setHasPendingSync(true); }, [canEdit], ); const flushAnswers = useCallback( async (options?: FlushAnswersOptions) => { if (!canEdit) { return; } if (flushPromiseRef.current) { await flushPromiseRef.current; } if (!hasPendingSyncRef.current && !options?.force) { return; } const payload = createPayload(answersRef.current, questionsRef.current); const revision = answersRevisionRef.current; if (payload.fields.length === 0) { return; } const request = mutateAsync(payload).then(() => 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; } hasPendingSyncRef.current = false; setHasPendingSync(false); writeStoredAnswers( storageKeyRef.current, slugRef.current, questionsRef.current, answersRef.current, false, ); }, [mutateAsync, canEdit], ); const flushAnswersRef = useRef(flushAnswers); useEffect(() => { flushAnswersRef.current = flushAnswers; }, [flushAnswers]); useEffect(() => { const flushWithKeepalive = () => { if (!canEdit) { return; } if (!hasPendingSyncRef.current) { return; } // The combined family card must be split across two backend endpoints by // updateMarriageSectionData. Sending its full payload to either endpoint // would overwrite the other half of the profile. The local pending draft // remains available and is retried on the next visit. if ( slugRef.current === "family_marital_history" || flushPromiseRef.current ) { return; } const payload = createPayload(answersRef.current, questionsRef.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; } fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify(payload), credentials: "include", headers, keepalive: true, method: "PATCH", }) .then((response) => { if (!response.ok) { return; } if (revision !== answersRevisionRef.current) { return; } hasPendingSyncRef.current = false; setHasPendingSync(false); writeStoredAnswers( storageKeyRef.current, slugRef.current, questionsRef.current, answersRef.current, false, ); 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, }), [ flushAnswers, getAnswerValue, hasPendingSync, isSaving, isLoadingData, setAnswerValue, ], ); 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, questionIndex: number, ) { const context = useContext(QuestionAnswersContext); return { setValue: (value: MarriageFieldValue) => { context?.setAnswerValue(question, questionIndex, value); }, value: context?.getAnswerValue(question, questionIndex), }; }