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>
376 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|