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

"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>
</>
);
}