You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

630 lines
16 KiB

"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<string, MarriageField>;
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<void>;
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<QuestionAnswersContextValue | null>(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<MarriageField>;
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<MarriagePhoneFieldValue>;
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<string>();
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<QuestionAnswersByKey>((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<StoredQuestionAnswers>;
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<QuestionAnswersByKey>({});
const [hasPendingSync, setHasPendingSync] = useState(false);
const { isPending: isSaving, mutateAsync } =
useUpdateMarriageSectionDataMutation(slug);
const answersRef = useRef<QuestionAnswersByKey>({});
const hasPendingSyncRef = useRef(false);
const answersRevisionRef = useRef(0);
const flushPromiseRef = useRef<Promise<void> | 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<NodeJS.Timeout | null>(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<string, string> = {
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<QuestionAnswersContextValue>(
() => ({
flushAnswers,
getAnswerValue,
hasPendingSync,
isSaving,
isLoading: isLoadingData,
setAnswerValue,
}),
[
flushAnswers,
getAnswerValue,
hasPendingSync,
isSaving,
isLoadingData,
setAnswerValue,
],
);
return (
<QuestionAnswersContext.Provider value={contextValue}>
{children}
</QuestionAnswersContext.Provider>
);
}
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),
};
}