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.
443 lines
15 KiB
443 lines
15 KiB
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
import { GoArrowLeft, GoArrowRight } from "react-icons/go";
|
|
import { useI18n } from "@/translations/provider";
|
|
|
|
import Button from "./button";
|
|
import { ExplanationUiFont } from "./explanation-ui-font";
|
|
import NavigationButton from "./navigation-button";
|
|
import { PageBackground } from "./page-background";
|
|
import StickyHeader from "./sticky-header";
|
|
import TestLoadingScreen from "./test-loading-screen";
|
|
|
|
export type QuestionOption = {
|
|
id: string;
|
|
label: string;
|
|
value: string | number;
|
|
};
|
|
|
|
export type TestQuestion = {
|
|
id: number;
|
|
text: string;
|
|
info?: string;
|
|
options: QuestionOption[];
|
|
};
|
|
|
|
type TestQuestionsFlowProps = {
|
|
title: string;
|
|
questions: TestQuestion[];
|
|
closeLabel: string;
|
|
informationLabel: string;
|
|
previousLabel?: string;
|
|
finishLabel?: string;
|
|
stepsLabel?: string;
|
|
onFinish?: (answers: Record<number, string | number>) => void;
|
|
onClose?: () => void;
|
|
draftStorageKey?: string | null;
|
|
};
|
|
type StoredTestDraft = {
|
|
answers?: Record<number, string | number>;
|
|
currentIndex?: number;
|
|
totalQuestions?: number;
|
|
};
|
|
|
|
function getStoredDraft(
|
|
storageKey: string | null | undefined,
|
|
totalQuestions: number,
|
|
) {
|
|
if (!storageKey || typeof window === "undefined")
|
|
return { answers: {}, currentIndex: 0 };
|
|
try {
|
|
const rawDraft = window.localStorage.getItem(storageKey);
|
|
if (!rawDraft) return { answers: {}, currentIndex: 0 };
|
|
const draft = JSON.parse(rawDraft) as StoredTestDraft;
|
|
return {
|
|
answers:
|
|
draft.answers && typeof draft.answers === "object" ? draft.answers : {},
|
|
currentIndex: Number.isInteger(draft.currentIndex)
|
|
? Math.min(
|
|
Math.max(draft.currentIndex ?? 0, 0),
|
|
Math.max(totalQuestions - 1, 0),
|
|
)
|
|
: 0,
|
|
};
|
|
} catch {
|
|
return { answers: {}, currentIndex: 0 };
|
|
}
|
|
}
|
|
|
|
function formatOptionLabel(str: string): string {
|
|
if (!str) return str;
|
|
|
|
let result = str.trim();
|
|
|
|
// Remove trailing numbers in parentheses, e.g., (1) or (۱)
|
|
result = result.replace(/\s*\([\d۱۲۳۴۵۶۷۸۹۰]+\)\s*$/, "");
|
|
|
|
if (result.startsWith(")")) {
|
|
result = result.slice(1).trim();
|
|
}
|
|
if (result.endsWith("(")) {
|
|
result = result.slice(0, -1).trim();
|
|
}
|
|
|
|
let openCount = 0;
|
|
let closeCount = 0;
|
|
for (let i = 0; i < result.length; i++) {
|
|
if (result[i] === "(") openCount++;
|
|
else if (result[i] === ")") closeCount++;
|
|
}
|
|
|
|
if (openCount > closeCount) {
|
|
result = result + ")".repeat(openCount - closeCount);
|
|
} else if (closeCount > openCount) {
|
|
result = "(".repeat(closeCount - openCount) + result;
|
|
}
|
|
|
|
if (result.length > 0) {
|
|
result = result.charAt(0).toUpperCase() + result.slice(1);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export default function TestQuestionsFlow({
|
|
title,
|
|
questions,
|
|
closeLabel,
|
|
informationLabel,
|
|
previousLabel = "Previous",
|
|
finishLabel = "Finish",
|
|
stepsLabel = "Steps",
|
|
onFinish,
|
|
onClose,
|
|
draftStorageKey,
|
|
}: TestQuestionsFlowProps) {
|
|
const router = useRouter();
|
|
const { locale } = useI18n();
|
|
const [currentIndex, setCurrentIndex] = useState(
|
|
() => getStoredDraft(draftStorageKey, questions.length).currentIndex,
|
|
);
|
|
const [answers, setAnswers] = useState<Record<number, string | number>>(
|
|
() => getStoredDraft(draftStorageKey, questions.length).answers,
|
|
);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const isTargetTest =
|
|
!!draftStorageKey &&
|
|
(draftStorageKey.includes("personality_test") ||
|
|
draftStorageKey.includes("glasser_5_needs_test"));
|
|
|
|
const [maxVisitedIndex, setMaxVisitedIndex] = useState(() => {
|
|
const initialIndex = getStoredDraft(
|
|
draftStorageKey,
|
|
questions.length,
|
|
).currentIndex;
|
|
const initialAnswers = getStoredDraft(
|
|
draftStorageKey,
|
|
questions.length,
|
|
).answers;
|
|
let highestAnswered = -1;
|
|
for (let i = 0; i < questions.length; i++) {
|
|
if (initialAnswers[questions[i].id] !== undefined) {
|
|
highestAnswered = i;
|
|
}
|
|
}
|
|
const furthestReached =
|
|
highestAnswered !== -1
|
|
? Math.min(highestAnswered + 1, questions.length - 1)
|
|
: 0;
|
|
return Math.max(initialIndex, furthestReached);
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (Object.keys(answers).length === 0) {
|
|
setMaxVisitedIndex(currentIndex);
|
|
} else if (currentIndex > maxVisitedIndex) {
|
|
setMaxVisitedIndex(currentIndex);
|
|
}
|
|
}, [currentIndex, answers, maxVisitedIndex]);
|
|
|
|
const currentQuestion = questions[currentIndex] ?? questions[0];
|
|
const totalQuestions = questions.length;
|
|
const isLastQuestion = currentIndex === totalQuestions - 1;
|
|
const selectedValue = currentQuestion
|
|
? answers[currentQuestion.id]
|
|
: undefined;
|
|
useEffect(() => {
|
|
if (!draftStorageKey) return;
|
|
try {
|
|
const match = draftStorageKey.match(
|
|
/^marriage:user:(\d+):tests:([^:]+):draft:v(\d+)$/,
|
|
);
|
|
const ownerProfileId = match ? Number(match[1]) : undefined;
|
|
const version = match ? Number(match[3]) : undefined;
|
|
const slug = match ? match[2] : undefined;
|
|
|
|
window.localStorage.setItem(
|
|
draftStorageKey,
|
|
JSON.stringify({
|
|
answers,
|
|
currentIndex,
|
|
totalQuestions,
|
|
...(ownerProfileId !== undefined ? { ownerProfileId } : {}),
|
|
...(version !== undefined ? { version } : {}),
|
|
...(slug !== undefined ? { slug } : {}),
|
|
}),
|
|
);
|
|
} catch {}
|
|
}, [answers, currentIndex, draftStorageKey, totalQuestions]);
|
|
|
|
const handleOptionSelect = (value: string | number) => {
|
|
if (!currentQuestion) return;
|
|
|
|
setAnswers((prev) => ({
|
|
...prev,
|
|
[currentQuestion.id]: value,
|
|
}));
|
|
|
|
// Auto advance to next question if not on last question with slide animation
|
|
if (!isLastQuestion) {
|
|
setTimeout(() => {
|
|
setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1));
|
|
}, 200);
|
|
}
|
|
};
|
|
|
|
const handlePrev = () => {
|
|
setCurrentIndex((prev) => Math.max(prev - 1, 0));
|
|
};
|
|
|
|
const handleNext = () => {
|
|
setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1));
|
|
};
|
|
|
|
const handleFinishSubmit = async () => {
|
|
if (isSubmitting) return;
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
if (onFinish) {
|
|
await onFinish(answers);
|
|
}
|
|
if (draftStorageKey) window.localStorage.removeItem(draftStorageKey);
|
|
router.back();
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (isSubmitting) {
|
|
return (
|
|
<TestLoadingScreen
|
|
title="Analyzing and submitting responses"
|
|
subtitle="Please wait a moment while your answers are reviewed and registered."
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!currentQuestion) {
|
|
return null;
|
|
}
|
|
|
|
const progressPercent =
|
|
(((isTargetTest ? maxVisitedIndex : currentIndex) + 1) / totalQuestions) *
|
|
100;
|
|
const areAllQuestionsAnswered = questions.every(
|
|
(question) => answers[question.id] !== undefined,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
|
|
{/* Header */}
|
|
<StickyHeader sticky={false} className="shrink-0">
|
|
<div className="flex items-center gap-4">
|
|
<NavigationButton
|
|
className="shrink-0"
|
|
variant="transparent"
|
|
icon="close"
|
|
iconLabel={closeLabel}
|
|
onClick={onClose}
|
|
/>
|
|
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate group-16">
|
|
{title}
|
|
</h1>
|
|
<NavigationButton
|
|
className="shrink-0"
|
|
variant="transparent"
|
|
icon="info"
|
|
iconLabel={informationLabel}
|
|
/>
|
|
</div>
|
|
</StickyHeader>
|
|
|
|
{/* Content Area */}
|
|
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-4 pb-4 min-h-0">
|
|
{/* Progress Section */}
|
|
<div className="shrink-0">
|
|
<div className="flex items-center justify-between group-12 font-medium text-[#7A7A7A] mb-2">
|
|
<span>{stepsLabel}</span>
|
|
<span>
|
|
{currentIndex + 1} /{totalQuestions}
|
|
</span>
|
|
</div>
|
|
{/* Progress Track & Fill */}
|
|
<div className="h-[6px] w-full rounded-full bg-[#E9E9E9] overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-linear-to-r from-[#F2465F] to-[#E03950] transition-all duration-300"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Question Slider Viewport */}
|
|
<div className="relative flex-1 w-full overflow-hidden min-h-0">
|
|
{questions.map((q, index) => {
|
|
const offset = index - currentIndex;
|
|
const isNearby = Math.abs(offset) <= 1;
|
|
|
|
if (!isNearby) return null;
|
|
|
|
const qSelectedValue = answers[q.id];
|
|
const options = q.options || [];
|
|
|
|
return (
|
|
<div
|
|
key={q.id}
|
|
aria-hidden={offset !== 0}
|
|
className={[
|
|
"absolute inset-0 flex flex-col justify-start overflow-y-auto pt-9 pb-4 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]",
|
|
offset === 0 ? "pointer-events-auto" : "pointer-events-none",
|
|
].join(" ")}
|
|
style={{
|
|
transform: `translate3d(${offset * 100}%, 0, 0)`,
|
|
}}
|
|
>
|
|
{/* Question Title */}
|
|
<h2
|
|
className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center px-3 mb-7 flex items-center justify-center shrink-0 min-h-[110px] sm:min-h-[120px]"
|
|
>
|
|
<span className="w-full">{q.text}</span>
|
|
</h2>
|
|
|
|
{/* Answer Options Stack */}
|
|
<div className="flex flex-col gap-3.5 pt-1">
|
|
{options.map((option) => {
|
|
const isSelected = qSelectedValue === option.value;
|
|
|
|
return (
|
|
<button
|
|
key={`${q.id}-${String(option.value)}`}
|
|
type="button"
|
|
onClick={() => handleOptionSelect(option.value)}
|
|
className={[
|
|
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3",
|
|
"w-full px-5 py-4 text-start leading-snug",
|
|
isSelected
|
|
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
|
|
: "bg-[#FAFAFA] text-[#181818] hover:bg-white shadow-[0_2px_6px_rgba(0,0,0,0.03)] border border-[#F0EDED]",
|
|
].join(" ")}
|
|
>
|
|
<div
|
|
className={[
|
|
"size-[18px] shrink-0 rounded-full border-[2px] transition-all flex items-center justify-center",
|
|
isSelected
|
|
? "border-white bg-white text-[#F0445B]"
|
|
: "border-[#D0D5DD] bg-white text-transparent",
|
|
].join(" ")}
|
|
>
|
|
{isSelected && (
|
|
<div className="size-[8px] rounded-full bg-current" />
|
|
)}
|
|
</div>
|
|
|
|
{option.label.includes(" - ") ? (
|
|
(() => {
|
|
const parts = option.label.split(" - ");
|
|
const title = formatOptionLabel(parts[0]);
|
|
const description = formatOptionLabel(
|
|
parts.slice(1).join(" - "),
|
|
);
|
|
return (
|
|
<span className="flex flex-col gap-1 text-start flex-1">
|
|
<span className="font-bold">{title}</span>
|
|
<ExplanationUiFont
|
|
textColor={
|
|
isSelected
|
|
? "text-white/85"
|
|
: "text-[#667085]"
|
|
}
|
|
>
|
|
{description}
|
|
</ExplanationUiFont>
|
|
</span>
|
|
);
|
|
})()
|
|
) : (
|
|
<span className="flex-1">
|
|
{formatOptionLabel(option.label)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Bottom Actions Bar */}
|
|
<div className="shrink-0 pt-2 flex items-center justify-between gap-4">
|
|
{/* Previous Button */}
|
|
<button
|
|
type="button"
|
|
onClick={handlePrev}
|
|
disabled={currentIndex === 0}
|
|
className="flex items-center gap-1.5 group-14 font-semibold text-[#4A4A4A] disabled:opacity-30 disabled:pointer-events-none transition-opacity py-2 px-1"
|
|
>
|
|
<GoArrowLeft className="size-5 rtl:rotate-180" />
|
|
<span>{locale === "fa" ? "قبلی" : previousLabel}</span>
|
|
</button>
|
|
|
|
{/* Next Button */}
|
|
{isTargetTest &&
|
|
!isLastQuestion &&
|
|
selectedValue !== undefined &&
|
|
currentIndex < maxVisitedIndex && (
|
|
<button
|
|
type="button"
|
|
onClick={handleNext}
|
|
className="flex items-center gap-1.5 group-14 font-semibold text-[#4A4A4A] transition-opacity py-2 px-1"
|
|
>
|
|
<span>{locale === "fa" ? "بعدی" : "Next"}</span>
|
|
<GoArrowRight className="size-5 rtl:rotate-180" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Finish Button on Last Question */}
|
|
{isLastQuestion ? (
|
|
<Button
|
|
className="!w-auto !h-[42px] px-7"
|
|
disabled={
|
|
selectedValue === undefined ||
|
|
!areAllQuestionsAnswered ||
|
|
isSubmitting
|
|
}
|
|
onClick={handleFinishSubmit}
|
|
>
|
|
{finishLabel}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|