Files
pwa/src/pages/Assessment/AssessmentPage.tsx
T
ar.badieiandCursor 8016b8f45e Polish Novya UI with brand colors, first-load skeletons, and session transition overlay.
Keep chat pinned to the latest message while AI thinks or streams, and align buttons/inputs with the logo teal/blue focus treatment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 01:31:24 +03:30

376 lines
14 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useAuth } from '@/hooks/useAuth';
import { useLearningJourney } from '@/hooks/useLearningJourney';
import {
saveAssessment,
type AttemptPhase,
} from '@/services/assessmentService';
import { BackendError } from '@/services/backend';
import { getBackendErrorMessage, isBackendNetworkError } from '@/lib/backendError';
import { useToast } from '@/components/ui/use-toast';
import { PCQ24_QUESTIONS, PCQ_LIKERT_OPTIONS } from '@/config/pcq24';
import { cn } from '@/lib/utils';
import { AssessmentSkeleton } from '@/components/skeletons';
import { LoadingOverlay } from '@/components/ui/loading-overlay';
const FADE_MS = 220;
export default function AssessmentPage() {
const { phase } = useParams<{ phase: AttemptPhase }>();
const navigate = useNavigate();
const { effectiveUserId } = useAuth();
const { toast } = useToast();
const { progress, loading, permissions, markPreTestDone, markPostTestDone, reloadProgress } = useLearningJourney(
effectiveUserId ?? undefined
);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
const [showIntro, setShowIntro] = useState(true);
const [contentVisible, setContentVisible] = useState(true);
const [isAnimating, setIsAnimating] = useState(false);
const isPreTest = phase === 'pre';
const isPostTest = phase === 'post';
const invalidPhase = !isPreTest && !isPostTest;
const canAccess = useMemo(() => {
if (isPreTest) return permissions.canTakePreTest;
if (isPostTest) return permissions.canTakePostTest;
return false;
}, [isPostTest, isPreTest, permissions.canTakePostTest, permissions.canTakePreTest]);
const totalQuestions = PCQ24_QUESTIONS.length;
const currentQuestion = PCQ24_QUESTIONS[currentIndex];
const selectedValue = answers[currentQuestion.id];
const isLastQuestion = currentIndex === totalQuestions - 1;
const allAnswered = PCQ24_QUESTIONS.every((q) => Boolean(answers[q.id]));
const canSubmit = allAnswered && !submitting && !isRedirecting;
const progressPercent = ((currentIndex + 1) / totalQuestions) * 100;
if (!effectiveUserId) {
return <Navigate to="/login" replace />;
}
if (invalidPhase) {
return <Navigate to="/" replace />;
}
if (!loading && !canAccess) {
if (isPreTest && progress?.pre_test_completed_at) {
return <Navigate to="/" replace />;
}
if (isPostTest && progress?.post_test_completed_at) {
return <Navigate to="/finished" replace />;
}
if (isPostTest) {
return <Navigate to="/" replace />;
}
}
if (loading) {
return <AssessmentSkeleton />;
}
const advanceWithFade = (delta: number) => {
if (isAnimating || submitting || isRedirecting) return;
setIsAnimating(true);
setContentVisible(false);
window.setTimeout(() => {
setCurrentIndex((prev) => Math.max(0, Math.min(prev + delta, totalQuestions - 1)));
setContentVisible(true);
window.setTimeout(() => setIsAnimating(false), FADE_MS);
}, FADE_MS);
};
const onSelect = (questionId: string, value: string) => {
if (isAnimating || submitting || isRedirecting) return;
setAnswers((prev) => ({ ...prev, [questionId]: value }));
if (!isLastQuestion) {
setIsAnimating(true);
window.setTimeout(() => {
setContentVisible(false);
window.setTimeout(() => {
setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1));
setContentVisible(true);
window.setTimeout(() => setIsAnimating(false), FADE_MS);
}, FADE_MS);
}, 90);
}
};
const handleSubmit = async () => {
if (PCQ24_QUESTIONS.some((q) => !answers[q.id])) {
toast({
title: 'پاسخ ناقص',
description: 'لطفا به همه سوال‌ها پاسخ دهید.',
variant: 'destructive',
});
return;
}
if (isPreTest && progress?.pre_test_completed_at) {
navigate('/', { replace: true });
return;
}
if (isPostTest) {
if (!permissions.canTakePostTest) {
navigate(progress?.post_test_completed_at ? '/finished' : '/', { replace: true });
return;
}
if (progress?.post_test_completed_at) {
navigate('/finished', { replace: true });
return;
}
}
setSubmitting(true);
try {
const rawAnswers = PCQ24_QUESTIONS.map((question) => ({
questionId: question.id,
selectedValue: answers[question.id],
}));
const submission = await saveAssessment({
userId: effectiveUserId,
phase: isPreTest ? 'pre' : 'post',
answers: rawAnswers,
});
if (isPreTest) {
await markPreTestDone(submission.completedAt);
await reloadProgress();
setSuccessMessage('پیش‌آزمون با موفقیت ثبت شد. در حال انتقال به فضای چت...');
setIsRedirecting(true);
setTimeout(() => navigate('/chat'), 800);
return;
}
await markPostTestDone(submission.completedAt);
await reloadProgress();
setSuccessMessage('پس‌آزمون با موفقیت ثبت شد. در حال انتقال به صفحه پایان دوره...');
setIsRedirecting(true);
setTimeout(() => navigate('/finished'), 800);
} catch (error: unknown) {
console.error('Error saving assessment:', error);
if (error instanceof BackendError) {
if (error.status === 401) {
toast({
title: 'نشست شما منقضی شده است',
description: 'لطفاً دوباره وارد حساب خود شوید.',
variant: 'destructive',
});
navigate('/login', { replace: true });
return;
}
if (error.status === 409) {
if (isPreTest) {
await markPreTestDone();
await reloadProgress();
navigate('/chat', { replace: true });
return;
}
toast({
title: 'قبلا ثبت شده است',
description: 'پس آزمون قبلا ثبت شده است.',
variant: 'default',
});
navigate('/finished', { replace: true });
return;
}
if (error.status === 422) {
toast({
title: 'پاسخ‌ها نامعتبر هستند',
description: 'لطفاً پاسخ‌ها را بررسی کنید و دوباره تلاش کنید.',
variant: 'destructive',
});
return;
}
}
if (isBackendNetworkError(error)) {
toast({
title: 'سرور در دسترس نیست',
description: 'ثبت آزمون انجام نشد. لطفاً چند لحظه بعد دوباره تلاش کنید.',
variant: 'destructive',
});
return;
}
toast({
title: 'خطا',
description: getBackendErrorMessage(error, 'ثبت آزمون با مشکل مواجه شد. لطفاً دوباره تلاش کنید.'),
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
const introTitle = isPreTest ? 'پیش‌آزمون' : 'پس‌آزمون';
const introBody = isPreTest ? (
<>
<p>
قبل از شروع مسیر یادگیری، لازم است وضعیت سرمایه روان‌شناختی شما را بسنجیم. این پرسشنامه کوتاه
(PCQ-24) به ما کمک می‌کند آموزش را متناسب با شرایط شما پیش ببریم.
</p>
<p className="text-sm text-slate-300/90">
پاسخ درست یا غلط وجود ندارد؛ گزینه‌ای را انتخاب کنید که بیشتر با تجربه واقعی شما همخوانی دارد.
</p>
</>
) : (
<p>
پس از پایان آموزش، پرسشنامه سرمایه روان‌شناختی (PCQ-24) را دوباره تکمیل کنید تا پیشرفت شما مشخص
شود.
</p>
);
return (
<div className="relative h-full min-h-0 flex-1 overflow-hidden p-4">
<LoadingOverlay
open={isRedirecting}
message={successMessage ?? 'در حال انتقال…'}
/>
{/* Intro overlay — dark veil on text area; bottom almost visible */}
{showIntro && (
<div
className="absolute inset-0 z-40 flex flex-col p-5 pt-6"
role="dialog"
aria-modal="true"
aria-labelledby="assessment-intro-title"
>
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'linear-gradient(to bottom, rgba(0,0,0,0.97) 0%, rgba(0,0,0,0.95) 70%, rgba(0,0,0,0.88) 85%, rgba(0,0,0,0.35) 92%, rgba(0,0,0,0.08) 100%)',
}}
aria-hidden
/>
<div className="animate-in fade-in relative z-10 flex w-full flex-col items-stretch gap-5 text-right duration-500">
<h2
id="assessment-intro-title"
className="text-xl font-semibold text-white drop-shadow-lg"
>
{isPreTest ? 'راهنمای پیش‌آزمون' : 'راهنمای پس‌آزمون'}
</h2>
<div className="space-y-4 text-sm leading-7 text-slate-100">{introBody}</div>
<Button
type="button"
variant="neon"
className="mt-2 w-full"
onClick={() => setShowIntro(false)}
>
متوجه شدم
</Button>
</div>
</div>
)}
{/* Questions surface */}
<div
className={cn(
'mx-auto flex h-full w-full max-w-lg flex-col gap-3',
showIntro && 'pointer-events-none'
)}
>
<div className="shrink-0 space-y-2">
<div className="flex items-center justify-between text-sm text-body">
<span className="font-medium text-cyan-800 dark:text-neon-cyan">{introTitle}</span>
<span className="font-medium tabular-nums text-foreground">{`سوال ${currentIndex + 1} از ${totalQuestions}`}</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
<div className="glass-panel flex min-h-0 flex-1 flex-col overflow-hidden p-4">
{/* Fixed question zone — number + text stay pinned (no vertical reflow) */}
<div className="relative h-[9.5rem] shrink-0 overflow-hidden pt-1">
<div
className={cn(
'absolute inset-x-0 top-0 transition-opacity duration-[220ms] ease-out',
contentVisible ? 'opacity-100' : 'opacity-0'
)}
>
<p className="mb-2 text-sm font-semibold tabular-nums text-cyan-950 dark:text-neon-teal">
{`سوال ${currentIndex + 1}`}
</p>
<h3 className="line-clamp-4 text-base font-semibold leading-7 text-foreground">
{currentQuestion.text}
</h3>
</div>
</div>
{/* Fixed answers grid — same slot for every question */}
<div
className={cn(
'mt-3 grid shrink-0 grid-cols-1 gap-2 transition-opacity duration-[220ms] ease-out',
contentVisible ? 'opacity-100' : 'opacity-0'
)}
>
{PCQ_LIKERT_OPTIONS.map((option) => {
const selected = selectedValue === option.value;
return (
<button
key={`${currentQuestion.id}-${option.value}`}
type="button"
disabled={isAnimating || submitting || isRedirecting || showIntro}
onClick={() => onSelect(currentQuestion.id, option.value)}
className={cn(
'flex h-11 w-full items-center justify-start rounded-xl border px-4 text-right text-sm transition-all duration-200',
'shadow-[0_2px_4px_rgba(15,23,42,0.05)] focus-visible:outline-none',
'focus-visible:border-[#26d4ca] focus-visible:shadow-[0_0_0_1px_rgba(38,212,202,0.35),0_3px_8px_rgba(15,23,42,0.08)]',
selected
? 'border-[#26d4ca] bg-[rgb(38_212_202/0.12)] text-foreground shadow-[0_0_0_1px_rgba(38,212,202,0.25),0_3px_8px_rgba(15,23,42,0.08)] dark:border-neon-cyan dark:bg-neon-cyan/15 dark:text-white dark:shadow-glow-sm'
: 'border-slate-200 bg-white/80 text-body hover:border-[#26d4ca]/70 hover:bg-white hover:text-foreground dark:border-neon-cyan/20 dark:bg-slate-900/35 dark:hover:border-neon-cyan/55 dark:hover:bg-slate-800/45 dark:hover:text-white'
)}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="grid shrink-0 grid-cols-2 gap-2">
<Button
type="button"
variant="glass"
disabled={currentIndex === 0 || isAnimating || submitting || isRedirecting || showIntro}
onClick={() => advanceWithFade(-1)}
>
سوال قبلی
</Button>
{isLastQuestion ? (
<Button
variant="neon"
className="w-full"
onClick={handleSubmit}
disabled={!canSubmit || showIntro || isAnimating}
>
{submitting ? 'در حال ثبت...' : 'ثبت آزمون'}
</Button>
) : (
<Button
type="button"
variant="neon"
className="w-full"
disabled={!selectedValue || isAnimating || submitting || isRedirecting || showIntro}
onClick={() => advanceWithFade(1)}
>
سوال بعدی
</Button>
)}
</div>
</div>
</div>
);
}