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.
 
 
 
 
 

348 lines
11 KiB

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { GoArrowLeft } from "react-icons/go";
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 = {
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;
};
type StoredTestDraft = {
answers?: Record<number, string | number>;
currentIndex?: number;
};
function getStoredDraft(
storageKey: string | 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 [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 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 {
window.localStorage.setItem(
draftStorageKey,
JSON.stringify({ answers, currentIndex }),
);
} catch {}
}, [answers, currentIndex, draftStorageKey]);
const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return;
setAnswers((prev) => ({
...prev,
[currentQuestion.id]: value,
}));
// Auto advance to next question if not on last question
if (!isLastQuestion) {
setTimeout(() => {
setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1));
}, 250);
}
};
const handlePrev = () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
};
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 = ((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 Section */}
<div className="flex-1 flex flex-col justify-start overflow-y-auto pt-6 pb-4">
{/* Question Title */}
<h2
className="group-16 font-bold text-[#1B1B1B] leading-[1.45] text-center px-2 mb-8 flex items-center justify-center"
style={{ minHeight: "5.8em" }}
>
<span className="w-full">{currentQuestion.text}</span>
</h2>
{/* Answer Options Stack */}
{(() => {
const options = currentQuestion.options || [];
return (
<div className="flex flex-col gap-3.5 pt-1">
{options.map((option) => {
const isSelected = selectedValue === option.value;
return (
<button
key={`${currentQuestion.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>
{/* 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" />
<span>{previousLabel}</span>
</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>
</>
);
}