Browse Source

feat: implement interactive questions-list page with local progress tracking and sync-before-submission logic

front-test-2
ghorbani 2 weeks ago
parent
commit
f70b3fd273
  1. 132
      src/app/questions-list/[slug]/answer-pace-sheet.tsx
  2. 9
      src/app/questions-list/[slug]/question-detail-client.tsx
  3. 12
      src/app/questions-list/page.tsx
  4. 18
      src/app/questions-list/sections-request.tsx
  5. 18
      src/components/Componentes/progress-helper.ts
  6. 104
      src/components/Componentes/question-answer-storage.tsx
  7. 20
      src/components/Componentes/question-exit-navigation-button.tsx
  8. 150
      src/components/Componentes/question-phone.tsx
  9. 151
      src/components/Componentes/question-section-flow.tsx
  10. 28
      src/components/Componentes/question-snap-list.tsx
  11. 13
      src/components/Componentes/silent-reloader.tsx
  12. 10
      src/components/Componentes/token-switcher.tsx
  13. 128
      src/hooks/marriage/use-form-schema.ts
  14. 5
      src/hooks/marriage/use-profile-main.ts
  15. 140
      src/hooks/marriage/use-section-data.ts
  16. 36
      src/hooks/marriage/use-sections.ts
  17. 23
      src/lib/first-entry-helper.ts
  18. 11
      src/lib/get-submit-path.ts

132
src/app/questions-list/[slug]/answer-pace-sheet.tsx

@ -1,134 +1,59 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import {
getQuestionAnswersStorageKey,
hasQuestionAnswerValue,
} from "@/components/Componentes/question-answer-storage";
import { useEffect, useState } from "react";
import InformationSheet from "@/components/Componentes/information-sheet"; import InformationSheet from "@/components/Componentes/information-sheet";
import type {
MarriageField,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { isFirstEntryCompleted } from "@/lib/first-entry-helper";
const ANSWER_PACE_SHEET_SEEN_KEY = "marriage:answer-pace-sheet-seen";
type AnswerPaceSheetProps = { type AnswerPaceSheetProps = {
activeQuestionIndex: number;
continueLabel: string; continueLabel: string;
description: string; description: string;
slug: string;
title: string; title: string;
}; };
type StoredQuestionAnswers = {
fields?: unknown;
};
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" ||
isMarriagePhoneFieldValue(field.value))
);
}
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
export function isAnswerPaceSheetSeen(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(ANSWER_PACE_SHEET_SEEN_KEY) === "true";
} catch {
return false; return false;
} }
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
} }
function hasStoredQuestionProgress(slugs: readonly string[]) {
export function markAnswerPaceSheetSeen(): void {
if (typeof window === "undefined") return;
try { try {
return slugs.some((slug) => {
const rawValue = window.localStorage.getItem(
getQuestionAnswersStorageKey(slug),
);
if (!rawValue) {
return false;
}
const storedValue = JSON.parse(rawValue) as StoredQuestionAnswers;
const fields = Array.isArray(storedValue.fields)
? storedValue.fields.filter(isMarriageField)
: [];
return fields.some((field) => hasQuestionAnswerValue(field.value));
});
window.localStorage.setItem(ANSWER_PACE_SHEET_SEEN_KEY, "true");
} catch { } catch {
return false;
// ignore
} }
} }
export default function AnswerPaceSheet({ export default function AnswerPaceSheet({
activeQuestionIndex,
continueLabel, continueLabel,
description, description,
slug,
title, title,
}: AnswerPaceSheetProps) { }: AnswerPaceSheetProps) {
const { data: sections, isSuccess } = useMarriageSectionsQuery();
const [hasSeenSheet, setHasSeenSheet] = useState(true);
const [hasLocalProgress, setHasLocalProgress] = useState(true);
const storageKey = `marriage:sections:${slug}:answer-pace-sheet-seen`;
const sectionSlugs = useMemo(
() => sections?.map((section) => section.slug) ?? [slug],
[sections, slug],
);
const hasAnySectionProgress = useMemo(() => {
if (!sections?.length) {
return true;
}
return sections.some(
(section) => section.current_step > 0 || section.completion_percent >= 1,
);
}, [sections]);
const isOpen =
isSuccess && !hasSeenSheet && !hasLocalProgress && !hasAnySectionProgress;
const [isOpen, setIsOpen] = useState(false);
useEffect(() => { useEffect(() => {
try {
setHasSeenSheet(window.sessionStorage.getItem(storageKey) === "true");
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
setHasSeenSheet(false);
// Show only once ever, during first entry flow, after 3rd question (activeQuestionIndex >= 3)
if (
activeQuestionIndex >= 3 &&
!isAnswerPaceSheetSeen() &&
!isFirstEntryCompleted()
) {
setIsOpen(true);
} }
setHasLocalProgress(hasStoredQuestionProgress(sectionSlugs));
}, [sectionSlugs, storageKey]);
}, [activeQuestionIndex]);
useEffect(() => {
if (!isOpen) {
return;
}
try {
window.sessionStorage.setItem(storageKey, "true");
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
}, [isOpen, storageKey]);
const handleClose = () => {
markAnswerPaceSheetSeen();
setIsOpen(false);
};
if (!isOpen) { if (!isOpen) {
return null; return null;
@ -140,6 +65,7 @@ export default function AnswerPaceSheet({
title={title} title={title}
description={description} description={description}
buttons={continueLabel} buttons={continueLabel}
onClose={handleClose}
/> />
); );
} }

9
src/app/questions-list/[slug]/question-detail-client.tsx

@ -39,7 +39,6 @@ import {
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import AnswerPaceSheet from "./answer-pace-sheet";
type QuestionDetailClientProps = { type QuestionDetailClientProps = {
closeLabel: string; closeLabel: string;
@ -655,7 +654,7 @@ export default function QuestionDetailClient({
router.replace(questionsListHref); router.replace(questionsListHref);
}, [isProfileLoading, item, profileContext, questionsListHref, router]); }, [isProfileLoading, item, profileContext, questionsListHref, router]);
if (isProfileLoading && item) {
if (!profile && isProfileLoading && item) {
return ( return (
<PageLoadingSkeleton <PageLoadingSkeleton
compact compact
@ -971,12 +970,6 @@ export default function QuestionDetailClient({
return ( return (
<> <>
<PageBackground disabled /> <PageBackground disabled />
<AnswerPaceSheet
slug={item.slug}
title={title}
description={description}
continueLabel={continueLabel}
/>
<QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}> <QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}>
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">

12
src/app/questions-list/page.tsx

@ -35,6 +35,7 @@ import {
} from "@/lib/match-start-grace"; } from "@/lib/match-start-grace";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { triggerSilentReload } from "@/components/Componentes/silent-reloader";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
export default function QuestionsListPage() { export default function QuestionsListPage() {
@ -45,9 +46,12 @@ export default function QuestionsListPage() {
const { data: profile, isLoading: isProfileLoading } = const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery(); useMarriageProfileQuery();
const { data: sections, isLoading: isSectionsLoading } = const { data: sections, isLoading: isSectionsLoading } =
useMarriageSectionsQuery({
refetchOnMount: "always",
});
useMarriageSectionsQuery();
useEffect(() => {
triggerSilentReload(queryClient);
}, [queryClient]);
const startMatchMutation = useStartMarriageMatchMutation({ const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => { onSuccess: () => {
@ -231,7 +235,7 @@ export default function QuestionsListPage() {
} }
}; };
if (isProfileLoading || isSectionsLoading) {
if ((!profile || !sections) && (isProfileLoading || isSectionsLoading)) {
return ( return (
<> <>
<PageBackground disabled /> <PageBackground disabled />

18
src/app/questions-list/sections-request.tsx

@ -37,11 +37,13 @@ export default function SectionsRequest() {
useEffect(() => { useEffect(() => {
try { try {
setHasSeenSheet(
window.sessionStorage.getItem(FIRST_ENTRY_TERMS_SEEN_KEY) === "true",
);
const seenLocal =
window.localStorage.getItem(FIRST_ENTRY_TERMS_SEEN_KEY) === "true";
const seenSession =
window.sessionStorage.getItem(FIRST_ENTRY_TERMS_SEEN_KEY) === "true";
setHasSeenSheet(seenLocal || seenSession);
} catch (e) { } catch (e) {
console.warn("sessionStorage is not accessible:", e);
console.warn("Storage is not accessible:", e);
setHasSeenSheet(false); setHasSeenSheet(false);
} }
}, []); }, []);
@ -52,9 +54,10 @@ export default function SectionsRequest() {
} }
try { try {
window.localStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true");
window.sessionStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true"); window.sessionStorage.setItem(FIRST_ENTRY_TERMS_SEEN_KEY, "true");
} catch (e) { } catch (e) {
console.warn("sessionStorage is not accessible:", e);
console.warn("Storage is not accessible:", e);
} }
}, [isOpen]); }, [isOpen]);
@ -68,11 +71,11 @@ export default function SectionsRequest() {
title={({ close }) => ( title={({ close }) => (
<span className="flex w-full items-start justify-between gap-3 text-left"> <span className="flex w-full items-start justify-between gap-3 text-left">
<span className="text-[14px] leading-5 font-bold tracking-normal text-[#8B8B8B]"> <span className="text-[14px] leading-5 font-bold tracking-normal text-[#8B8B8B]">
Bookings Terms &amp; Conditions
Terms &amp; Conditions
</span> </span>
<button <button
type="button" type="button"
aria-label="Close booking terms"
aria-label="Close terms and conditions"
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]" className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]"
onClick={close} onClick={close}
> >
@ -96,3 +99,4 @@ export default function SectionsRequest() {
/> />
); );
} }

18
src/components/Componentes/progress-helper.ts

@ -201,8 +201,15 @@ export function getLocalSectionProgress(
const findAnswer = (question: any, questionIndex: number) => { const findAnswer = (question: any, questionIndex: number) => {
const key = getQuestionFieldKey(question, questionIndex); const key = getQuestionFieldKey(question, questionIndex);
const engSlug = slugifyTitle(question.englishTitle || question.title);
const field = fields.find( const field = fields.find(
(f) => f && (f.key === key || f.label === question.title),
(f) =>
f &&
(f.key === key ||
f.label === question.title ||
f.label === question.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
); );
return field?.value; return field?.value;
}; };
@ -491,8 +498,15 @@ export function getLocalSectionProgress(
const answeredCount = requiredQuestions.filter((q) => { const answeredCount = requiredQuestions.filter((q) => {
const idx = profileVisible.indexOf(q); const idx = profileVisible.indexOf(q);
const key = getQuestionFieldKey(q, idx); const key = getQuestionFieldKey(q, idx);
const engSlug = slugifyTitle(q.englishTitle || q.title);
const field = fields.find( const field = fields.find(
(f) => f && (f.key === key || f.label === q.title),
(f) =>
f &&
(f.key === key ||
f.label === q.title ||
f.label === q.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
); );
return isFieldAnswered(q, field); return isFieldAnswered(q, field);
}).length; }).length;

104
src/components/Componentes/question-answer-storage.tsx

@ -59,6 +59,7 @@ type QuestionAnswersContextValue = {
questionIndex: number, questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
) => void; ) => void;
backendFields: MarriageField[];
}; };
type QuestionAnswersProviderProps = { type QuestionAnswersProviderProps = {
@ -150,13 +151,61 @@ function isMarriagePhoneFieldValue(
); );
} }
function findQuestionFieldKey(
question: QuestionField,
questionIndex: number,
answers?: QuestionAnswersByKey,
backendFields?: MarriageField[],
): string {
const legacyKey = getQuestionFieldKey(question, questionIndex);
if (backendFields && backendFields.length > 0) {
const engSlug = slugifyQuestionTitle(question.englishTitle || question.title);
// 1. Try to match by slug
const matchBySlug = backendFields.find((f) =>
typeof f.key === "string" && (f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`))
);
if (matchBySlug) return matchBySlug.key;
// 2. Try to match by label
const matchByLabel = backendFields.find((f) =>
f.label === question.title || f.label === question.englishTitle
);
if (matchByLabel) return matchByLabel.key;
// 3. Fallback to index
if (backendFields[questionIndex]) {
return backendFields[questionIndex].key;
}
}
if (!answers) return legacyKey;
const engSlug = slugifyQuestionTitle(question.englishTitle || question.title);
const foundEntry = Object.values(answers).find(
(f) =>
f &&
(f.key === legacyKey ||
f.label === question.title ||
f.label === question.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
);
return foundEntry?.key || legacyKey;
}
function createQuestionField( function createQuestionField(
question: QuestionField, question: QuestionField,
questionIndex: number, questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
currentAnswers?: QuestionAnswersByKey,
backendFields?: MarriageField[],
): MarriageField { ): MarriageField {
const key = findQuestionFieldKey(question, questionIndex, currentAnswers, backendFields);
return { return {
key: getQuestionFieldKey(question, questionIndex),
key,
label: question.title, label: question.title,
type: question.type, type: question.type,
value, value,
@ -167,12 +216,13 @@ function createQuestionField(
function getOrderedFields( function getOrderedFields(
answers: QuestionAnswersByKey, answers: QuestionAnswersByKey,
questions: readonly QuestionField[], questions: readonly QuestionField[],
backendFields?: MarriageField[],
) { ) {
const orderedFields: MarriageField[] = []; const orderedFields: MarriageField[] = [];
const orderedKeys = new Set<string>(); const orderedKeys = new Set<string>();
questions.forEach((question, index) => { questions.forEach((question, index) => {
const key = getQuestionFieldKey(question, index);
const key = findQuestionFieldKey(question, index, answers, backendFields);
const field = answers[key]; const field = answers[key];
if (field) { if (field) {
@ -193,12 +243,18 @@ function getOrderedFields(
function getCurrentStep( function getCurrentStep(
fields: MarriageField[], fields: MarriageField[],
questions: readonly QuestionField[], questions: readonly QuestionField[],
backendFields?: MarriageField[],
) { ) {
return questions.filter((question, index) => { return questions.filter((question, index) => {
if (!question.required || question.logic?.dependsOn) { if (!question.required || question.logic?.dependsOn) {
return false; return false;
} }
const key = getQuestionFieldKey(question, index);
const key = findQuestionFieldKey(
question,
index,
fieldsToAnswers(fields),
backendFields
);
const field = fields.find((f) => f.key === key); const field = fields.find((f) => f.key === key);
return field && hasQuestionAnswerValue(field.value); return field && hasQuestionAnswerValue(field.value);
}).length; }).length;
@ -207,14 +263,15 @@ function getCurrentStep(
function createPayload( function createPayload(
answers: QuestionAnswersByKey, answers: QuestionAnswersByKey,
questions: readonly QuestionField[], questions: readonly QuestionField[],
backendFields?: MarriageField[],
): UpdateMarriageSectionDataPayload { ): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions);
const fields = getOrderedFields(answers, questions, backendFields);
const targetQuestions = questions.filter( const targetQuestions = questions.filter(
(q) => q.required && !q.logic?.dependsOn, (q) => q.required && !q.logic?.dependsOn,
); );
return { return {
current_step: getCurrentStep(fields, questions),
current_step: getCurrentStep(fields, questions, backendFields),
total_steps: targetQuestions.length, total_steps: targetQuestions.length,
fields, fields,
}; };
@ -263,9 +320,10 @@ function writeStoredAnswers(
questions: readonly QuestionField[], questions: readonly QuestionField[],
answers: QuestionAnswersByKey, answers: QuestionAnswersByKey,
pendingSync: boolean, pendingSync: boolean,
backendFields?: MarriageField[],
) { ) {
try { try {
const payload = createPayload(answers, questions);
const payload = createPayload(answers, questions, backendFields);
if (payload.fields.length === 0) { if (payload.fields.length === 0) {
window.localStorage.removeItem(storageKey); window.localStorage.removeItem(storageKey);
@ -318,6 +376,7 @@ export function QuestionAnswersProvider({
const questionsRef = useRef(questions); const questionsRef = useRef(questions);
const storageKeyRef = useRef(storageKey); const storageKeyRef = useRef(storageKey);
const slugRef = useRef(slug); const slugRef = useRef(slug);
const backendFieldsRef = useRef<MarriageField[]>([]);
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
const canEdit = profile?.can_edit_profile !== false; const canEdit = profile?.can_edit_profile !== false;
@ -326,8 +385,12 @@ export function QuestionAnswersProvider({
useMarriageSectionDataQuery(slug); useMarriageSectionDataQuery(slug);
useEffect(() => { useEffect(() => {
answersRef.current = answers;
hasPendingSyncRef.current = hasPendingSync;
slugRef.current = slug;
questionsRef.current = questions; questionsRef.current = questions;
}, [questions]);
backendFieldsRef.current = serverSectionData?.data || [];
}, [answers, hasPendingSync, slug, questions, serverSectionData?.data]);
useEffect(() => { useEffect(() => {
storageKeyRef.current = storageKey; storageKeyRef.current = storageKey;
@ -359,11 +422,12 @@ export function QuestionAnswersProvider({
// Update localStorage to stay in sync // Update localStorage to stay in sync
writeStoredAnswers( writeStoredAnswers(
storageKey,
getQuestionAnswersStorageKey(slug),
slug, slug,
questions, questions,
finalAnswers, finalAnswers,
finalPendingSync, finalPendingSync,
serverSectionData?.data || undefined
); );
}, [slug, storageKey, serverSectionData, questions, canEdit]); }, [slug, storageKey, serverSectionData, questions, canEdit]);
@ -378,9 +442,11 @@ export function QuestionAnswersProvider({
}, []); }, []);
const getAnswerValue = useCallback( const getAnswerValue = useCallback(
(question: QuestionField, questionIndex: number) =>
answers[getQuestionFieldKey(question, questionIndex)]?.value,
[answers],
(question: QuestionField, questionIndex: number) => {
const key = findQuestionFieldKey(question, questionIndex, answers, serverSectionData?.data || undefined);
return answers[key]?.value;
},
[answers, serverSectionData?.data],
); );
const setAnswerValue = useCallback( const setAnswerValue = useCallback(
@ -392,7 +458,13 @@ export function QuestionAnswersProvider({
if (!canEdit) { if (!canEdit) {
return; return;
} }
const field = createQuestionField(question, questionIndex, value);
const field = createQuestionField(
question,
questionIndex,
value,
answersRef.current,
serverSectionData?.data || undefined,
);
setAnswers((currentAnswers) => { setAnswers((currentAnswers) => {
const nextAnswers = { const nextAnswers = {
@ -430,7 +502,7 @@ export function QuestionAnswersProvider({
}); });
setHasPendingSync(true); setHasPendingSync(true);
}, },
[canEdit],
[canEdit, serverSectionData?.data],
); );
const flushAnswers = useCallback( const flushAnswers = useCallback(
@ -447,7 +519,7 @@ export function QuestionAnswersProvider({
return; return;
} }
const payload = createPayload(answersRef.current, questionsRef.current);
const payload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current);
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
if (payload.fields.length === 0) { if (payload.fields.length === 0) {
@ -512,7 +584,7 @@ export function QuestionAnswersProvider({
return; return;
} }
const payload = createPayload(answersRef.current, questionsRef.current);
const payload = createPayload(answersRef.current, questionsRef.current, backendFieldsRef.current);
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
if (payload.fields.length === 0) { if (payload.fields.length === 0) {
@ -585,6 +657,7 @@ export function QuestionAnswersProvider({
isSaving, isSaving,
isLoading: isLoadingData, isLoading: isLoadingData,
setAnswerValue, setAnswerValue,
backendFields: serverSectionData?.data || [],
}), }),
[ [
flushAnswers, flushAnswers,
@ -593,6 +666,7 @@ export function QuestionAnswersProvider({
isSaving, isSaving,
isLoadingData, isLoadingData,
setAnswerValue, setAnswerValue,
serverSectionData?.data,
], ],
); );

20
src/components/Componentes/question-exit-navigation-button.tsx

@ -2,12 +2,15 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import NavigationButton, { import NavigationButton, {
type NavigationButtonProps, type NavigationButtonProps,
} from "./navigation-button"; } from "./navigation-button";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import { markFirstEntryCompleted } from "@/lib/first-entry-helper";
import { triggerSilentReload } from "./silent-reloader";
export type QuestionExitNavigationButtonProps = NavigationButtonProps & { export type QuestionExitNavigationButtonProps = NavigationButtonProps & {
exitHref?: string; exitHref?: string;
@ -18,15 +21,14 @@ export function QuestionExitNavigationButton({
...props ...props
}: QuestionExitNavigationButtonProps) { }: QuestionExitNavigationButtonProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { locale } = useI18n(); const { locale } = useI18n();
const { flushAnswers } = useQuestionAnswers(); const { flushAnswers } = useQuestionAnswers();
const [isLeaving, setIsLeaving] = useState(false);
return ( return (
<NavigationButton <NavigationButton
{...props} {...props}
disabled={props.disabled || isLeaving}
onClick={async (event) => {
onClick={(event) => {
props.onClick?.(event); props.onClick?.(event);
if (event.defaultPrevented) { if (event.defaultPrevented) {
@ -34,19 +36,17 @@ export function QuestionExitNavigationButton({
} }
event.preventDefault(); event.preventDefault();
setIsLeaving(true);
try { try {
await Promise.race([
flushAnswers({ force: true }),
new Promise((resolve) => setTimeout(resolve, 500)),
]);
markFirstEntryCompleted();
void flushAnswers({ force: true });
} catch { } catch {
// ignore // ignore
} finally {
}
triggerSilentReload(queryClient);
const target = localizePath(exitHref || "/questions-list", locale); const target = localizePath(exitHref || "/questions-list", locale);
router.push(target); router.push(target);
}
}} }}
/> />
); );

150
src/components/Componentes/question-phone.tsx

@ -232,30 +232,54 @@ export function QuestionPhone({
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex); const value = getAnswerValue(question, questionIndex);
const defaultCodeValue = countryCode.trim() || "+44"; const defaultCodeValue = countryCode.trim() || "+44";
const initialValue = readPhoneValue(value, defaultCodeValue);
const [codeValue, setCodeValue] = useState(initialValue.codeValue);
const [phoneValue, setPhoneValue] = useState(initialValue.phoneValue);
const getCachedOrSavedCode = useCallback((): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("geoIPPhoneCode");
}, []);
const initialCode = useMemo(() => {
if (isMarriagePhoneFieldValue(value) && value.countryCode) {
return value.countryCode.startsWith("+")
? value.countryCode
: `+${value.countryCode}`;
}
if (typeof value === "string" && value.length > 0) {
const parts = readPhoneValue(value, defaultCodeValue);
if (parts.codeValue && parts.codeValue !== defaultCodeValue) {
return parts.codeValue;
}
}
const cached = getCachedOrSavedCode();
if (cached) return cached;
return defaultCodeValue;
}, [value, defaultCodeValue, getCachedOrSavedCode]);
const initialPhone = useMemo(() => {
return readPhoneValue(value, defaultCodeValue).phoneValue;
}, [value, defaultCodeValue]);
const [codeValue, setCodeValue] = useState(initialCode);
const [phoneValue, setPhoneValue] = useState(initialPhone);
const lastCommittedValueRef = useRef(value); const lastCommittedValueRef = useRef(value);
const hasFetchedIpRef = useRef(false); const hasFetchedIpRef = useRef(false);
const userInteractedRef = useRef(false); const userInteractedRef = useRef(false);
// Determine if we need to resolve country code (loading from backend or fetching IP)
const needsIpFetch = () => {
const needsIpFetch = useCallback(() => {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
// If there's a cached code or we already checked, no need to fetch
if (localStorage.getItem("geoIPPhoneCode")) return false; if (localStorage.getItem("geoIPPhoneCode")) return false;
if (localStorage.getItem("hasCheckedGeoIPPhone")) return false; if (localStorage.getItem("hasCheckedGeoIPPhone")) return false;
return true; return true;
};
}, []);
const [isResolvingCode, setIsResolvingCode] = useState(() => { const [isResolvingCode, setIsResolvingCode] = useState(() => {
// On first render: if backend is loading or we need an IP fetch, show loading
if (isLoading) return true; if (isLoading) return true;
// If there's already a saved value in the initial render, no loading needed
if (
initialValue.phoneValue ||
(initialValue.codeValue && initialValue.codeValue !== defaultCodeValue)
)
return false;
const hasSavedValue =
value &&
((isMarriagePhoneFieldValue(value) &&
(value.countryCode || value.phoneNumber)) ||
(typeof value === "string" && value.trim().length > 0));
if (hasSavedValue) return false;
return needsIpFetch(); return needsIpFetch();
}); });
@ -378,28 +402,36 @@ export function QuestionPhone({
}; };
}, []); }, []);
// Sync state if external value changes (e.g. backend data loaded)
useEffect(() => { useEffect(() => {
if (value === lastCommittedValueRef.current) { if (value === lastCommittedValueRef.current) {
return; return;
} }
const nextValue = readPhoneValue(value, defaultCodeValue); const nextValue = readPhoneValue(value, defaultCodeValue);
const maxLen = getMaxLengthForCountry(nextValue.codeValue);
const cachedCode = getCachedOrSavedCode();
const resolvedCode =
(isMarriagePhoneFieldValue(value) && value.countryCode) ||
(typeof value === "string" &&
value.trim().length > 0 &&
nextValue.codeValue !== defaultCodeValue)
? nextValue.codeValue
: cachedCode || defaultCodeValue;
const maxLen = getMaxLengthForCountry(resolvedCode);
const truncatedPhone = nextValue.phoneValue.slice(0, maxLen); const truncatedPhone = nextValue.phoneValue.slice(0, maxLen);
setCodeValue(nextValue.codeValue);
setCodeValue(resolvedCode);
setPhoneValue(truncatedPhone); setPhoneValue(truncatedPhone);
lastCommittedValueRef.current = value; lastCommittedValueRef.current = value;
}, [defaultCodeValue, value]);
}, [defaultCodeValue, value, getCachedOrSavedCode]);
// Non-blocking background IP resolution on first visit
useEffect(() => { useEffect(() => {
// Wait until backend data is loaded before deciding
if (isLoading) return; if (isLoading) return;
if (hasFetchedIpRef.current) return; if (hasFetchedIpRef.current) return;
if (userInteractedRef.current) {
setIsResolvingCode(false);
return;
}
if (userInteractedRef.current) return;
// If user already has a saved value from backend, use it — no IP check needed // If user already has a saved value from backend, use it — no IP check needed
const hasSavedValue = const hasSavedValue =
@ -410,41 +442,40 @@ export function QuestionPhone({
if (hasSavedValue) { if (hasSavedValue) {
hasFetchedIpRef.current = true; hasFetchedIpRef.current = true;
setIsResolvingCode(false);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPPhone", "true"); localStorage.setItem("hasCheckedGeoIPPhone", "true");
if (isMarriagePhoneFieldValue(value) && value.countryCode) {
const code = value.countryCode.startsWith("+")
? value.countryCode
: `+${value.countryCode}`;
localStorage.setItem("geoIPPhoneCode", code);
setCodeValue(code);
}
} }
return; return;
} }
// Check if we already fetched IP in a previous session and cached the result
// Check if we already fetched IP in a previous session or determined code
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const cachedCode = localStorage.getItem("geoIPPhoneCode"); const cachedCode = localStorage.getItem("geoIPPhoneCode");
if (cachedCode) {
const alreadyChecked = localStorage.getItem("hasCheckedGeoIPPhone");
if (cachedCode || alreadyChecked === "true") {
hasFetchedIpRef.current = true; hasFetchedIpRef.current = true;
if (cachedCode) {
setCodeValue(cachedCode); setCodeValue(cachedCode);
setIsResolvingCode(false);
return;
} }
// If we already checked and got nothing useful, don't check again
const alreadyChecked = localStorage.getItem("hasCheckedGeoIPPhone");
if (alreadyChecked) {
hasFetchedIpRef.current = true;
setIsResolvingCode(false);
return; return;
} }
} }
// First time ever — fetch country code from IP
// First time ever — fetch country code from IP with strict 1.5s timeout
hasFetchedIpRef.current = true; hasFetchedIpRef.current = true;
setIsResolvingCode(true);
const applyCode = (code: string) => { const applyCode = (code: string) => {
if (userInteractedRef.current) return; if (userInteractedRef.current) return;
const ipCode = code.startsWith("+") ? code : `+${code}`; const ipCode = code.startsWith("+") ? code : `+${code}`;
setCodeValue(ipCode); setCodeValue(ipCode);
setIsResolvingCode(false);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", ipCode); localStorage.setItem("geoIPPhoneCode", ipCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true"); localStorage.setItem("hasCheckedGeoIPPhone", "true");
@ -453,17 +484,21 @@ export function QuestionPhone({
const applyFallback = () => { const applyFallback = () => {
if (userInteractedRef.current) return; if (userInteractedRef.current) return;
// Default to UK (+44) if IP lookup fails
setCodeValue("+44");
setIsResolvingCode(false);
const fallbackCode = "+44";
setCodeValue(fallbackCode);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", fallbackCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true"); localStorage.setItem("hasCheckedGeoIPPhone", "true");
} }
}; };
fetch("https://ipapi.co/json/")
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
fetch("https://ipapi.co/json/", { signal: controller.signal })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
clearTimeout(timeoutId);
if (data && data.country_calling_code) { if (data && data.country_calling_code) {
applyCode(data.country_calling_code); applyCode(data.country_calling_code);
} else { } else {
@ -471,9 +506,17 @@ export function QuestionPhone({
} }
}) })
.catch(() => { .catch(() => {
fetch("https://ipwho.is/")
clearTimeout(timeoutId);
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
1500,
);
fetch("https://ipwho.is/", { signal: secondaryController.signal })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
clearTimeout(secondaryTimeoutId);
if (data && data.calling_code) { if (data && data.calling_code) {
applyCode(data.calling_code); applyCode(data.calling_code);
} else { } else {
@ -481,9 +524,14 @@ export function QuestionPhone({
} }
}) })
.catch(() => { .catch(() => {
clearTimeout(secondaryTimeoutId);
applyFallback(); applyFallback();
}); });
}); });
return () => {
clearTimeout(timeoutId);
};
}, [isLoading, value]); }, [isLoading, value]);
const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => { const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => {
@ -506,6 +554,7 @@ export function QuestionPhone({
const handleSelectCountryCode = (selectedCode: string) => { const handleSelectCountryCode = (selectedCode: string) => {
userInteractedRef.current = true; userInteractedRef.current = true;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", selectedCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true"); localStorage.setItem("hasCheckedGeoIPPhone", "true");
} }
const maxLen = getMaxLengthForCountry(selectedCode); const maxLen = getMaxLengthForCountry(selectedCode);
@ -533,23 +582,11 @@ export function QuestionPhone({
dir="ltr" dir="ltr"
className={[ className={[
"flex h-[54px] w-full items-center rounded-[16px] border bg-white text-[#181818] focus-within:border-[#6F6F6F] focus-within:ring-1 focus-within:ring-[#6F6F6F] transition-all", "flex h-[54px] w-full items-center rounded-[16px] border bg-white text-[#181818] focus-within:border-[#6F6F6F] focus-within:ring-1 focus-within:ring-[#6F6F6F] transition-all",
isResolvingCode
? "border-[#D0D5DD]"
: showInvalidState
showInvalidState
? "border-[#F2465F] ring-1 ring-[#F2465F]" ? "border-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]", : "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")} ].join(" ")}
> >
{isResolvingCode ? (
/* Loading skeleton while determining country code */
<div className="flex w-full items-center gap-3 px-4">
<LoadingSkeleton className="size-5 rounded-full" />
<LoadingSkeleton className="h-4 w-12" />
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/15" />
<LoadingSkeleton className="h-4 flex-1" />
</div>
) : (
<>
<div className="flex shrink-0 items-center pl-2.5 pr-2"> <div className="flex shrink-0 items-center pl-2.5 pr-2">
<button <button
type="button" type="button"
@ -597,6 +634,7 @@ export function QuestionPhone({
onChange={(event) => { onChange={(event) => {
userInteractedRef.current = true; userInteractedRef.current = true;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("geoIPPhoneCode", codeValue);
localStorage.setItem("hasCheckedGeoIPPhone", "true"); localStorage.setItem("hasCheckedGeoIPPhone", "true");
} }
const nextPhoneValue = sanitizePhoneNumber( const nextPhoneValue = sanitizePhoneNumber(
@ -612,10 +650,8 @@ export function QuestionPhone({
className="h-full w-full border-0 bg-transparent p-0 text-left text-[15px] font-medium leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#98A2B3]" className="h-full w-full border-0 bg-transparent p-0 text-left text-[15px] font-medium leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#98A2B3]"
/> />
</span> </span>
</>
)}
</div> </div>
{!isResolvingCode && showInvalidState ? (
{showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]"> <span className="block group-10 font-semibold text-[#F2465F]">
Enter a valid phone number with country code. Enter a valid phone number with country code.
</span> </span>

151
src/components/Componentes/question-section-flow.tsx

@ -3,7 +3,8 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
@ -15,6 +16,11 @@ import type { QuestionField } from "@/data/question-data";
import NoticeBox from "./notice-box"; import NoticeBox from "./notice-box";
import { getStoredAge } from "./progress-helper"; import { getStoredAge } from "./progress-helper";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet";
import { FixToTheEnd } from "./fix-to-the-end";
import Button from "./button";
import { markFirstEntryCompleted } from "@/lib/first-entry-helper";
import { triggerSilentReload } from "./silent-reloader";
type QuestionSectionFlowProps = { type QuestionSectionFlowProps = {
children: ReactNode; children: ReactNode;
@ -39,16 +45,35 @@ function SectionFlowContent({
questions?: readonly QuestionField[]; questions?: readonly QuestionField[];
}) { }) {
const router = useRouter(); const router = useRouter();
const { locale, dictionary: t } = useI18n();
const queryClient = useQueryClient();
const { dictionary: t, locale } = useI18n();
const { flushAnswers } = useQuestionAnswers(); const { flushAnswers } = useQuestionAnswers();
const { isCompleted, markQuestionPassed } = useQuestionProgress();
const [isLeaving, setIsLeaving] = useState(false);
const { markQuestionPassed, isCompleted } = useQuestionProgress();
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleQuestionExit = useCallback(() => { const handleQuestionExit = useCallback(() => {
void flushAnswers({ force: true }); void flushAnswers({ force: true });
}, [flushAnswers]); }, [flushAnswers]);
const handleSubmit = useCallback(() => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
markFirstEntryCompleted();
void flushAnswers({ force: true });
} catch {
// ignore
}
triggerSilentReload(queryClient);
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
}, [exitHref, flushAnswers, locale, router, isSubmitting, queryClient]);
const markOptionalQuestionsPassed = useCallback( const markOptionalQuestionsPassed = useCallback(
(currentIndex: number, nextIndex: number) => { (currentIndex: number, nextIndex: number) => {
[currentIndex, nextIndex].forEach((questionIndex) => { [currentIndex, nextIndex].forEach((questionIndex) => {
@ -60,104 +85,6 @@ function SectionFlowContent({
[markQuestionPassed, optionalQuestionIndexes], [markQuestionPassed, optionalQuestionIndexes],
); );
const handleContinue = useCallback(async () => {
if (!isCompleted) return;
setIsLeaving(true);
try {
await Promise.race([
flushAnswers({ force: true }),
new Promise((resolve) => setTimeout(resolve, 800)),
]);
} catch {
// Ignore background sync errors during transition
} finally {
const targetHref = localizePath(exitHref || "/questions-list", locale);
router.push(targetHref);
}
}, [exitHref, flushAnswers, isCompleted, locale, router]);
useEffect(() => {
if (!isCompleted || isLeaving) return;
if (activeQuestionIndex !== (questions?.length ?? 0) - 1) return;
let isScheduled = false;
let timerId: NodeJS.Timeout | null = null;
const checkAndSubmit = () => {
const activeEl = document.activeElement;
const isTyping =
activeEl &&
(activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA");
if (!isTyping) {
if (!isScheduled) {
isScheduled = true;
timerId = setTimeout(() => {
void handleContinue();
}, 800);
}
}
};
checkAndSubmit();
const handleBlur = () => {
setTimeout(() => {
const activeEl = document.activeElement;
const stillTyping =
activeEl &&
(activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA");
if (!stillTyping && !isScheduled) {
isScheduled = true;
timerId = setTimeout(() => {
void handleContinue();
}, 400);
}
}, 100);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
const target = e.target as HTMLElement;
if (
target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA")
) {
target.blur();
if (!isScheduled) {
isScheduled = true;
timerId = setTimeout(() => {
void handleContinue();
}, 400);
}
}
}
};
const activeEl = document.activeElement;
if (activeEl) {
activeEl.addEventListener("blur", handleBlur);
}
window.addEventListener("keydown", handleKeyDown);
return () => {
if (activeEl) {
activeEl.removeEventListener("blur", handleBlur);
}
window.removeEventListener("keydown", handleKeyDown);
if (timerId) {
clearTimeout(timerId);
}
};
}, [
isCompleted,
isLeaving,
activeQuestionIndex,
questions?.length,
handleContinue,
]);
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
const isFemale = profile?.gender === "female"; const isFemale = profile?.gender === "female";
const age = getStoredAge(); const age = getStoredAge();
@ -168,6 +95,16 @@ function SectionFlowContent({
return ( return (
<> <>
<AnswerPaceSheet
activeQuestionIndex={activeQuestionIndex}
title={t["Answer at Your Own Pace"]}
description={
t[
"You can pause the survey anytime and resume later. Your progress is saved automatically."
]
}
continueLabel={continueLabel || t["Submit"]}
/>
{showNotice && ( {showNotice && (
<div className="w-full px-[17px] mb-6 shrink-0"> <div className="w-full px-[17px] mb-6 shrink-0">
<NoticeBox> <NoticeBox>
@ -198,6 +135,16 @@ function SectionFlowContent({
> >
{children} {children}
</QuestionSnapList> </QuestionSnapList>
<FixToTheEnd>
<Button
disabled={!isCompleted || isSubmitting}
isLoading={isSubmitting}
onClick={handleSubmit}
>
{continueLabel || t["Submit"]}
</Button>
</FixToTheEnd>
</> </>
); );
} }

28
src/components/Componentes/question-snap-list.tsx

@ -23,7 +23,6 @@ const AUTO_FOCUS_SELECTOR = [
type QuestionSnapListProps = { type QuestionSnapListProps = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
footer?: ReactNode;
firstQuestionHint?: ReactNode; firstQuestionHint?: ReactNode;
onQuestionExit?: (currentIndex: number, nextIndex: number) => void; onQuestionExit?: (currentIndex: number, nextIndex: number) => void;
onQuestionTransition?: (currentIndex: number, nextIndex: number) => void; onQuestionTransition?: (currentIndex: number, nextIndex: number) => void;
@ -34,7 +33,6 @@ type QuestionSnapListProps = {
export function QuestionSnapList({ export function QuestionSnapList({
children, children,
className, className,
footer,
firstQuestionHint, firstQuestionHint,
onQuestionExit, onQuestionExit,
onQuestionTransition, onQuestionTransition,
@ -51,21 +49,7 @@ export function QuestionSnapList({
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
// Track whether section was already completed on mount
const wasCompletedOnMountRef = useRef<boolean | null>(null);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasCompletedOnMountRef.current === null) {
// First time: capture the initial state
wasCompletedOnMountRef.current = isCompleted;
return;
}
// Only show button if it was NOT completed on mount and now becomes completed
if (!wasCompletedOnMountRef.current && isCompleted) {
setJustCompleted(true);
}
}, [isCompleted]);
const stepQuestion = useCallback( const stepQuestion = useCallback(
(direction: 1 | -1) => { (direction: 1 | -1) => {
@ -388,21 +372,11 @@ export function QuestionSnapList({
</div> </div>
); );
})} })}
{footer && activeIndex === questions.length - 1 ? (
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(24px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
{footer}
</div>
</div>
) : null}
{firstQuestionHint ? ( {firstQuestionHint ? (
<div <div
aria-hidden="true" aria-hidden="true"
className={[ className={[
"pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2",
"pointer-events-none absolute bottom-24 left-1/2 -translate-x-1/2",
"transition-opacity duration-500 motion-safe:animate-bounce", "transition-opacity duration-500 motion-safe:animate-bounce",
activeIndex === 0 ? "opacity-100" : "opacity-0", activeIndex === 0 ? "opacity-100" : "opacity-0",
].join(" ")} ].join(" ")}

13
src/components/Componentes/silent-reloader.tsx

@ -59,3 +59,16 @@ export default function SilentReloader({ children }: SilentReloaderProps) {
return <>{children}</>; return <>{children}</>;
} }
export function triggerSilentReload(queryClient?: any) {
if (queryClient && typeof queryClient.invalidateQueries === "function") {
void queryClient.invalidateQueries();
} else if (
typeof window !== "undefined" &&
(window as any).__queryClient &&
typeof (window as any).__queryClient.invalidateQueries === "function"
) {
void (window as any).__queryClient.invalidateQueries();
}
}

10
src/components/Componentes/token-switcher.tsx

@ -66,7 +66,7 @@ export function TokenSwitcher({
setIsOpen(false); setIsOpen(false);
// Navigate to the beginning of the flow (intro page) // Navigate to the beginning of the flow (intro page)
window.location.href = "/";
window.location.replace("/");
} }
}; };
@ -81,10 +81,14 @@ export function TokenSwitcher({
} }
try { try {
// 1. Clear local/session storage
// 1. Clear local storage and session storage completely
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try {
window.localStorage.clear(); window.localStorage.clear();
window.sessionStorage.clear(); window.sessionStorage.clear();
} catch (e) {
console.error("Failed to clear storage", e);
}
} }
// 2. Call backend reset script via API route if it is a real DB user // 2. Call backend reset script via API route if it is a real DB user
@ -127,7 +131,7 @@ export function TokenSwitcher({
} }
setIsOpen(false); setIsOpen(false);
window.location.href = "/";
window.location.replace("/");
} catch (error) { } catch (error) {
console.error("Failed to reset user:", error); console.error("Failed to reset user:", error);
alert("خطایی در انجام عملیات رخ داد."); alert("خطایی در انجام عملیات رخ داد.");

128
src/hooks/marriage/use-form-schema.ts

@ -0,0 +1,128 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
export interface FormOption {
id: string;
value: string;
label: string;
}
export interface FormQuestion {
id: string;
type: string;
title: string;
description: string;
tooltip: string;
placeholder: string;
required: boolean;
show_guardian_notice: boolean;
validation: Record<string, any>;
ui_config: Record<string, any>;
logic: Record<string, any> | null;
is_visible: boolean;
options: FormOption[];
}
export interface FormCard {
id: string;
title: string;
questions: FormQuestion[];
}
export interface FormSection {
id: string;
title: string;
icon: string;
is_required: boolean;
estimated_minutes: number;
cards: FormCard[];
}
export interface FormProgressInfo {
current_step: number;
total_steps: number;
completion_percent: number;
}
export interface FormSchemaResponse {
form_id: string;
version: number;
sections: FormSection[];
answers: Record<string, { value: any; option_id: string }>;
progress: {
current_step: number;
total_steps: number;
completion_percent: number;
sections_progress: Record<string, FormProgressInfo>;
};
is_completed?: boolean;
}
export async function getFormSchema(formId: string, locale: string): Promise<FormSchemaResponse> {
const { data } = await http.get<FormSchemaResponse>(
`/api/marriage/forms/${formId}/?lang=${locale}`
);
return data;
}
export function useFormSchemaQuery<TData = FormSchemaResponse>(
formId: string,
locale: string,
options?: QueryOptions<FormSchemaResponse, TData>
) {
return useQuery({
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
...options,
queryFn: () => getFormSchema(formId, locale),
queryKey: ["marriage", "form-schema", formId, locale],
});
}
export interface SaveAnswersPayload {
version: number;
answers: Array<{
question_id: string;
value: any;
option_id?: string;
}>;
}
export async function saveFormAnswers(formId: string, payload: SaveAnswersPayload): Promise<FormSchemaResponse> {
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/${formId}/answers/`,
payload
);
return data;
}
export function useSaveFormAnswersMutation(
formId: string,
options?: MutationOptions<FormSchemaResponse, SaveAnswersPayload>
) {
const queryClient = useQueryClient();
return useMutation({
...options,
mutationFn: (payload) => saveFormAnswers(formId, payload),
onSuccess: async (data, variables, onMutateResult, context) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["marriage", "form-schema", formId],
}),
queryClient.invalidateQueries({
queryKey: marriageQueryKeys.profile(),
}),
queryClient.invalidateQueries({
queryKey: marriageQueryKeys.sections(),
}),
]);
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}

5
src/hooks/marriage/use-profile-main.ts

@ -18,9 +18,8 @@ export function useMarriageProfileQuery<TData = MarriageProfileResponse>(
options?: QueryOptions<MarriageProfileResponse, TData>, options?: QueryOptions<MarriageProfileResponse, TData>,
) { ) {
return useQuery({ return useQuery({
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
staleTime: 10 * 1000,
refetchOnWindowFocus: false,
...options, ...options,
queryFn: getMarriageProfile, queryFn: getMarriageProfile,
queryKey: marriageQueryKeys.profile(), queryKey: marriageQueryKeys.profile(),

140
src/hooks/marriage/use-section-data.ts

@ -11,6 +11,8 @@ import type {
MarriageSectionData, MarriageSectionData,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "./types"; } from "./types";
import type { FormSchemaResponse } from "./use-form-schema";
function hashString(value: string) { function hashString(value: string) {
let hash = 0; let hash = 0;
@ -52,6 +54,15 @@ function hasQuestionAnswerValue(value: unknown) {
export async function getMarriageSectionData( export async function getMarriageSectionData(
slug: string, slug: string,
): Promise<MarriageSectionData> { ): Promise<MarriageSectionData> {
const getClientCookie = (name: string) => {
if (typeof document === "undefined") return "en";
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(";").shift() ?? "en";
return "en";
};
const lang = getClientCookie("HABIB_LANGUAGE") || getClientCookie("habib_language") || "en";
if (slug === "family_marital_history") { if (slug === "family_marital_history") {
const [fbData, mhData] = await Promise.all([ const [fbData, mhData] = await Promise.all([
getMarriageSectionData("family_background"), getMarriageSectionData("family_background"),
@ -73,11 +84,50 @@ export async function getMarriageSectionData(
} }
const backendSlug = toBackendSlug(slug); const backendSlug = toBackendSlug(slug);
const { data } = await http.get<MarriageSectionData>(
`/api/marriage/sections/${pathParam(backendSlug)}/data/`,
const { data } = await http.get<any>(
`/api/marriage/forms/profile/?lang=${lang}`
); );
return data;
const sec = data.sections.find((s: any) => s.id === backendSlug);
if (!sec) {
return {
slug,
data: [],
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
updated_at: null,
};
}
const fields: any[] = [];
sec.cards.forEach((card: any) => {
card.questions.forEach((q: any) => {
const ans = data.answers[q.id];
fields.push({
key: q.id,
label: q.title,
type: q.type,
value: ans ? ans.value : null,
option_id: ans ? ans.option_id : undefined,
});
});
});
const prog = data.progress.sections_progress[backendSlug] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
slug,
data: fields,
current_step: prog.current_step,
total_steps: prog.total_steps,
completion_percent: prog.completion_percent,
updated_at: new Date().toISOString(),
};
} }
export async function updateMarriageSectionData( export async function updateMarriageSectionData(
@ -173,47 +223,73 @@ export async function updateMarriageSectionData(
return field && hasQuestionAnswerValue(field.value); return field && hasQuestionAnswerValue(field.value);
}).length; }).length;
const [fbResult, mhResult] = await Promise.all([
http.patch<MarriageSectionData>(
`/api/marriage/sections/family_background/data/`,
{
current_step: fbCurrentStep,
total_steps: fbRequiredCount,
fields: fbFields,
},
),
http.patch<MarriageSectionData>(
`/api/marriage/sections/marital_history/data/`,
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
}));
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/profile/answers/`,
{ {
current_step: mhCurrentStep,
total_steps: mhRequiredCount,
fields: mhFields,
},
),
]);
answers: answersPayload,
}
);
const fbProg = data.progress.sections_progress["family_background"] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
const mhProg = data.progress.sections_progress["marital_history"] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return { return {
slug: "family_marital_history", slug: "family_marital_history",
data: [...(fbResult.data.data || []), ...(mhResult.data.data || [])],
current_step: fbResult.data.current_step + mhResult.data.current_step,
total_steps: fbResult.data.total_steps + mhResult.data.total_steps,
data: payload.fields,
current_step: fbProg.current_step + mhProg.current_step,
total_steps: fbProg.total_steps + mhProg.total_steps,
completion_percent: completion_percent:
fbResult.data.total_steps + mhResult.data.total_steps > 0
? ((fbResult.data.current_step + mhResult.data.current_step) /
(fbResult.data.total_steps + mhResult.data.total_steps)) *
fbProg.total_steps + mhProg.total_steps > 0
? ((fbProg.current_step + mhProg.current_step) /
(fbProg.total_steps + mhProg.total_steps)) *
100 100
: 0, : 0,
updated_at: fbResult.data.updated_at || mhResult.data.updated_at,
updated_at: new Date().toISOString(),
}; };
} }
const backendSlug = toBackendSlug(slug);
const { data } = await http.patch<MarriageSectionData>(
`/api/marriage/sections/${pathParam(backendSlug)}/data/`,
payload,
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
}));
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/profile/answers/`,
{
answers: answersPayload,
}
); );
return data;
const backendSlug = toBackendSlug(slug);
const prog = data.progress.sections_progress[backendSlug] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
slug,
data: payload.fields,
current_step: prog.current_step,
total_steps: prog.total_steps,
completion_percent: prog.completion_percent,
updated_at: new Date().toISOString(),
};
} }
export function useMarriageSectionDataQuery<TData = MarriageSectionData>( export function useMarriageSectionDataQuery<TData = MarriageSectionData>(

36
src/hooks/marriage/use-sections.ts

@ -5,17 +5,49 @@ import { http } from "@/lib/http";
import type { QueryOptions } from "./options"; import type { QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys"; import { marriageQueryKeys } from "./query-keys";
import type { MarriageSection } from "./types"; import type { MarriageSection } from "./types";
import type { FormSchemaResponse } from "./use-form-schema";
export async function getMarriageSections() { export async function getMarriageSections() {
const { data } = await http.get<MarriageSection[]>("/api/marriage/sections/");
const getClientCookie = (name: string) => {
if (typeof document === "undefined") return "en";
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(";").shift() ?? "en";
return "en";
};
const lang = getClientCookie("HABIB_LANGUAGE") || getClientCookie("habib_language") || "en";
return data;
const { data } = await http.get<FormSchemaResponse>(
`/api/marriage/forms/profile/?lang=${lang}`
);
return data.sections.map((sec, idx) => {
const prog = data.progress.sections_progress[sec.id] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
id: idx + 1,
slug: sec.id,
title: sec.title,
is_required: sec.is_required,
importance_weight: 1,
estimated_minutes: sec.estimated_minutes,
total_steps: prog.total_steps,
order: idx,
current_step: prog.current_step,
completion_percent: prog.completion_percent,
};
});
} }
export function useMarriageSectionsQuery<TData = MarriageSection[]>( export function useMarriageSectionsQuery<TData = MarriageSection[]>(
options?: QueryOptions<MarriageSection[], TData>, options?: QueryOptions<MarriageSection[], TData>,
) { ) {
return useQuery({ return useQuery({
staleTime: 10 * 1000,
refetchOnWindowFocus: false,
...options, ...options,
queryFn: getMarriageSections, queryFn: getMarriageSections,
queryKey: marriageQueryKeys.sections(), queryKey: marriageQueryKeys.sections(),

23
src/lib/first-entry-helper.ts

@ -0,0 +1,23 @@
const FIRST_ENTRY_COMPLETED_KEY = "marriage:first-entry-completed";
export function isFirstEntryCompleted(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
return window.localStorage.getItem(FIRST_ENTRY_COMPLETED_KEY) === "true";
} catch {
return false;
}
}
export function markFirstEntryCompleted(): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(FIRST_ENTRY_COMPLETED_KEY, "true");
} catch {
// ignore
}
}

11
src/lib/get-submit-path.ts

@ -1,4 +1,5 @@
import type { MarriageProfileResponse } from "@/hooks/marriage/types"; import type { MarriageProfileResponse } from "@/hooks/marriage/types";
import { isFirstEntryCompleted } from "./first-entry-helper";
import { import {
clearLegacyMatchSubmittedFlag, clearLegacyMatchSubmittedFlag,
isWithinMatchStartGrace, isWithinMatchStartGrace,
@ -70,7 +71,15 @@ export function getSubmitPath(
// The backend may still report pending_info for a moment right after the // The backend may still report pending_info for a moment right after the
// match request is sent, so honour a short grace window to avoid bouncing // match request is sent, so honour a short grace window to avoid bouncing
// the user back into the questions list. // the user back into the questions list.
return isWithinMatchStartGrace() ? "/finding-match" : "/questions-list";
if (isWithinMatchStartGrace()) {
return "/finding-match";
}
if (!isFirstEntryCompleted()) {
return "/questions-list/personal_info";
}
return "/questions-list";
} }
return "/terms"; return "/terms";

Loading…
Cancel
Save