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.
723 lines
19 KiB
723 lines
19 KiB
"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<string, MarriageField>;
|
|
|
|
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<void>;
|
|
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<QuestionAnswersContextValue | null>(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<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,
|
|
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<string>();
|
|
|
|
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<QuestionAnswersByKey>((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<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),
|
|
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<QuestionAnswersByKey>({});
|
|
const [hasPendingSync, setHasPendingSync] = useState(false);
|
|
const { isPending: isSaving, mutateAsync } =
|
|
useUpdateMarriageSectionDataMutation(slug, locale);
|
|
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 backendFieldsRef = useRef<MarriageField[]>([]);
|
|
const dirtyKeysRef = useRef(new Set<string>());
|
|
|
|
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<NodeJS.Timeout | null>(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<string, string> = {
|
|
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<QuestionAnswersContextValue>(
|
|
() => ({
|
|
flushAnswers,
|
|
getAnswerValue,
|
|
hasPendingSync,
|
|
isSaving,
|
|
isLoading: isLoadingData,
|
|
setAnswerValue,
|
|
backendFields: serverSectionData?.data || [],
|
|
}),
|
|
[
|
|
flushAnswers,
|
|
getAnswerValue,
|
|
hasPendingSync,
|
|
isSaving,
|
|
isLoadingData,
|
|
setAnswerValue,
|
|
serverSectionData?.data,
|
|
],
|
|
);
|
|
|
|
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) {
|
|
const context = useContext(QuestionAnswersContext);
|
|
|
|
return {
|
|
setValue: (value: MarriageFieldValue) => {
|
|
context?.setAnswerValue(question, value);
|
|
},
|
|
value: context?.getAnswerValue(question),
|
|
};
|
|
}
|