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.
 
 
 
 
 

910 lines
27 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,
MarriageBirthplaceFieldValue,
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<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[];
answers: QuestionAnswersByKey;
};
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 || value === undefined) {
return false;
}
if (typeof value === "string") {
return value.trim().length > 0;
}
if (typeof value === "object") {
if (Array.isArray(value)) {
return value.length > 0;
}
const phone = value as Partial<MarriagePhoneFieldValue>;
if (
typeof phone.countryCode === "string" ||
typeof phone.phoneNumber === "string"
) {
return Boolean(phone.countryCode?.trim() || phone.phoneNumber?.trim());
}
const bp = value as Partial<MarriageBirthplaceFieldValue>;
if (typeof bp.country === "string" || typeof bp.city === "string") {
return Boolean(bp.country?.trim() || bp.city?.trim());
}
}
return true;
}
function isMarriageBirthplaceFieldValue(
value: unknown,
): value is MarriageBirthplaceFieldValue {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const bpValue = value as Partial<MarriageBirthplaceFieldValue>;
return (
typeof bpValue.country === "string" && typeof bpValue.city === "string"
);
}
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) ||
isMarriageBirthplaceFieldValue(field.value))
);
}
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object" || Array.isArray(value)) {
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;
const isChoiceType =
question.type === "dropdown" ||
question.type === "radio" ||
question.type === "checkbox" ||
question.type === "scale";
if (isChoiceType && question.options && Array.isArray(question.options) && question.options.length > 0) {
if (Array.isArray(value)) {
option_id = value;
} else if (typeof value === "string" && value) {
const strVal = value.trim().toLowerCase();
const selectedOpt = question.options.find(
(opt) =>
opt.id === value ||
opt.value === value ||
opt.label === value ||
opt.id.toLowerCase() === strVal ||
(typeof opt.value === "string" && opt.value.toLowerCase() === strVal) ||
(typeof opt.label === "string" && opt.label.toLowerCase() === strVal) ||
opt.id.toLowerCase().endsWith(`.${strVal}`)
);
option_id = selectedOpt ? selectedOpt.id : value;
}
}
const key = question.id;
return {
key,
label: question.title,
type: question.type,
value,
private: question.private,
option_id: isChoiceType ? option_id : undefined,
} 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) => {
const isChoice =
field.type === "dropdown" ||
field.type === "radio" ||
field.type === "checkbox" ||
field.type === "scale";
if (
isChoice &&
field.option_id !== undefined &&
field.option_id !== null &&
(!Array.isArray(field.option_id) || field.option_id.length > 0 || field.type === "checkbox")
) {
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 versionRef = useRef<number | undefined>(undefined);
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 || [];
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<string>();
// 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<string, ScopedPendingField> = {};
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<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);
// Write only dirty fields to profile-scoped localStorage
const currentProfileId = profile?.id;
if (currentProfileId) {
const pendingMap: Record<string, ScopedPendingField> = {};
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)) {
const isChoice =
question.type === "dropdown" ||
question.type === "radio" ||
question.type === "checkbox" ||
question.type === "scale";
const resolvedVal =
isChoice &&
answer.option_id !== undefined &&
answer.option_id !== null &&
(!Array.isArray(answer.option_id) ||
answer.option_id.length > 0 ||
question.type === "checkbox")
? answer.option_id
: answer.value;
nextAnswers[key] = {
key,
label: question.title,
type: question.type,
value: resolvedVal,
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<string, string> = {
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<string, ScopedPendingField> = {};
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<QuestionAnswersContextValue>(
() => ({
flushAnswers,
getAnswerValue,
hasPendingSync,
isSaving,
isLoading: isLoadingData,
setAnswerValue,
backendFields: serverSectionData?.data || [],
answers,
}),
[
flushAnswers,
getAnswerValue,
hasPendingSync,
isSaving,
isLoadingData,
setAnswerValue,
serverSectionData?.data,
answers,
],
);
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),
};
}