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.
 
 
 
 
 

278 lines
9.1 KiB

"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useState } from "react";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionProgressTracker, {
useQuestionProgress,
} from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list";
import type { QuestionField } from "@/lib/schema-adapter";
import ErrorToast from "./error-toast";
import NoticeBox from "./notice-box";
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 DevTapInstrumentation from "./dev-tap-instrumentation";
type QuestionSectionFlowProps = {
children: ReactNode;
continueLabel: string;
exitHref: string;
onExit?: () => void;
total: number;
optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[];
};
function SectionFlowContent({
children,
continueLabel,
exitHref,
onExit,
optionalQuestionIndexes,
questions,
}: {
children: ReactNode;
continueLabel: string;
exitHref: string;
onExit?: () => void;
optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[];
}) {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { flushAnswers, isUploadingMedia } = useQuestionAnswers();
const { markQuestionPassed, isCompleted } = useQuestionProgress();
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleQuestionExit = useCallback(() => {
void flushAnswers({ force: true });
}, [flushAnswers]);
const handleSubmit = useCallback(async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage(null);
const MAX_RETRIES = 3;
let success = false;
let lastError: any = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await flushAnswers({ force: true });
success = true;
break;
} catch (err: any) {
lastError = err;
console.warn(
`[CONTINUE] flushAnswers attempt ${attempt}/${MAX_RETRIES} failed:`,
err,
);
// On 400 Bad Request / Validation error, do not retry silently
const statusCode = err?.response?.status || err?.status;
if (statusCode === 400) {
break;
}
if (attempt < MAX_RETRIES) {
// Silent delay between retries while maintaining loading spinner
await new Promise((resolve) => setTimeout(resolve, 800));
}
}
}
if (success) {
markFirstEntryCompleted();
if (onExit) {
onExit();
} else {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
}
} else {
setIsSubmitting(false);
const statusCode = lastError?.response?.status || lastError?.status;
const data = lastError?.response?.data;
let msg = "";
// 1. Detailed field validation errors from backend
if (data?.errors && typeof data.errors === "object") {
const errorEntries = Object.entries(data.errors);
if (errorEntries.length > 0) {
const fieldNames: string[] = [];
for (const [key] of errorEntries) {
const matchingQuestion = questions?.find(
(q) =>
q.id === key ||
q.id.endsWith(`.${key}`) ||
key.endsWith(`.${q.id}`) ||
q.id.split(".").pop() === key.split(".").pop(),
);
if (matchingQuestion?.title) {
fieldNames.push(`«${matchingQuestion.title}»`);
} else {
fieldNames.push(`«${key}»`);
}
}
if (fieldNames.length > 0) {
const uniqueFieldNames = Array.from(new Set(fieldNames));
const template =
t["Please complete the required fields: {fields}"] ||
"Please complete the required fields: {fields}";
msg = template.replace("{fields}", uniqueFieldNames.join("، "));
}
}
}
// 2. Direct message / detail / error from backend
if (!msg) {
const directMessage = data?.detail || data?.message || data?.error;
if (typeof directMessage === "string" && directMessage.trim()) {
msg = directMessage;
}
}
// 3. Fallback for 400 Bad Request
if (!msg && statusCode === 400) {
msg =
t["Some required fields are missing or invalid. Please check your answers."] ||
"Some required fields are missing or invalid. Please check your answers.";
}
// 4. Fallback for 401 Unauthorized
if (!msg && statusCode === 401) {
msg =
t["Session expired. Please log in again."] ||
"Session expired. Please log in again.";
}
// 5. Fallback for Network / Server Error
if (!msg) {
msg =
t["Internet connection error. Answers could not be synced with server. Please check your connection and tap Continue again."] ||
"Internet connection error. Answers could not be synced with server. Please check your connection and tap Continue again.";
}
setErrorMessage(msg);
}
}, [exitHref, onExit, flushAnswers, locale, router, isSubmitting, t, questions]);
const markOptionalQuestionsPassed = useCallback(
(currentIndex: number, nextIndex: number) => {
[currentIndex, nextIndex].forEach((questionIndex) => {
if (optionalQuestionIndexes.includes(questionIndex)) {
markQuestionPassed(questionIndex);
}
});
},
[markQuestionPassed, optionalQuestionIndexes],
);
const activeQuestion = questions?.[activeQuestionIndex];
const showNotice = activeQuestion?.showGuardianNotice;
const isUnder27 = activeQuestion?.required ?? false;
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 && (
<div className="w-full px-[17px] mb-6 shrink-0">
<NoticeBox>
{isUnder27
? t[
"To preserve your peace of mind, security, and dignity, the acquaintance process on our platform follows authentic and respectful family traditions. Having a trusted person (preferably a father or mother) as a representative, in addition to reflecting your noble family background, encourages the other party to step forward with full seriousness, respect, and confidence."
]
: t[
"Our goal is to establish lasting bonds based on mutual trust. Although registering a representative is not mandatory for you, introducing a trusted person (such as a father, mother, or family elder) demonstrates your transparency and serious intent for marriage. Profiles that feature a trusted representative command significantly higher credibility and provide greater peace of mind to the other party's family."
]}
</NoticeBox>
</div>
)}
<QuestionSnapList
alignTop={showNotice}
firstQuestionHint={
<Image
src="/assets/images/Frame 1597880476.svg"
alt=""
aria-hidden="true"
width={31}
height={31}
/>
}
onQuestionExit={handleQuestionExit}
onQuestionTransition={markOptionalQuestionsPassed}
onActiveIndexChange={setActiveQuestionIndex}
>
{children}
</QuestionSnapList>
{process.env.NODE_ENV === "development" ? (
<DevTapInstrumentation />
) : null}
{errorMessage && (
<ErrorToast
message={errorMessage}
onClose={() => setErrorMessage(null)}
duration={5000}
variant="error"
/>
)}
<FixToTheEnd>
<Button
disabled={!isCompleted || isSubmitting || isUploadingMedia}
isLoading={isSubmitting || isUploadingMedia}
onClick={handleSubmit}
>
{continueLabel || t["Submit"]}
</Button>
</FixToTheEnd>
</>
);
}
export function QuestionSectionFlow({
children,
continueLabel,
exitHref,
onExit,
total,
optionalQuestionIndexes,
questions,
}: QuestionSectionFlowProps) {
return (
<QuestionProgressTracker total={total}>
<SectionFlowContent
continueLabel={continueLabel}
exitHref={exitHref}
onExit={onExit}
optionalQuestionIndexes={optionalQuestionIndexes}
questions={questions}
>
{children}
</SectionFlowContent>
</QuestionProgressTracker>
);
}
export default QuestionSectionFlow;