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.
 
 
 
 
 

425 lines
14 KiB

"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { GoArrowLeft, GoArrowRight } from "react-icons/go";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
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 TestExitSheet from "./test-exit-sheet";
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;
};
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(0);
const [answers, setAnswers] = useState<Record<number, string | number>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isExitSheetOpen, setIsExitSheetOpen] = useState(false);
const isTargetTest =
!!draftStorageKey &&
(draftStorageKey.includes("personality_test") ||
draftStorageKey.includes("glasser_5_needs_test"));
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0);
useEffect(() => {
if (Object.keys(answers).length === 0) {
setMaxVisitedIndex(currentIndex);
} else if (currentIndex > maxVisitedIndex) {
setMaxVisitedIndex(currentIndex);
}
}, [currentIndex, answers, maxVisitedIndex]);
const handleRequestClose = useCallback(() => {
setIsExitSheetOpen(true);
}, []);
useHardwareBackHandler(() => {
if (isExitSheetOpen) {
setIsExitSheetOpen(false);
return true;
}
setIsExitSheetOpen(true);
return true;
}, true);
const handleConfirmExit = useCallback(() => {
if (draftStorageKey) {
try {
window.localStorage.removeItem(draftStorageKey);
} catch {}
}
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("marriage:tests:personality_test:draft");
window.localStorage.removeItem("marriage:tests:glasser_5_needs_test:draft");
} catch {}
}
setAnswers({});
setCurrentIndex(0);
setIsExitSheetOpen(false);
if (onClose) {
onClose();
} else {
router.back();
}
}, [draftStorageKey, onClose, router]);
const currentQuestion = questions[currentIndex] ?? questions[0];
const totalQuestions = questions.length;
const isLastQuestion = currentIndex === totalQuestions - 1;
const selectedValue = currentQuestion
? answers[currentQuestion.id]
: undefined;
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) {
try {
window.localStorage.removeItem(draftStorageKey);
} catch {}
}
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("marriage:tests:personality_test:draft");
window.localStorage.removeItem("marriage:tests:glasser_5_needs_test:draft");
} catch {}
}
if (onClose) {
onClose();
} else {
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={handleRequestClose}
/>
<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 overflow-y-auto pt-4 pb-2 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-2 flex items-center justify-center shrink-0 min-h-[90px] sm:min-h-[100px]"
>
<span className="w-full">{q.text}</span>
</h2>
{/* Answer Options Stack */}
<div className="flex-1 flex flex-col justify-center gap-3.5 my-auto pb-3">
{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>
<TestExitSheet
isOpen={isExitSheetOpen}
onClose={() => setIsExitSheetOpen(false)}
onConfirmExit={handleConfirmExit}
/>
</>
);
}