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
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;
|