Persian age validation messages, clearer homework entry points, and seed assignment definitions so «تکالیف من» is not empty after deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
269 lines
9.9 KiB
TypeScript
269 lines
9.9 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { useLearningJourney } from '@/hooks/useLearningJourney';
|
|
import { useCurrentSession } from '@/hooks/useCurrentSession';
|
|
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
|
import { getProgramProgress } from '@/services/programService';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { Navigate, useNavigate } from 'react-router-dom';
|
|
import { Check, ChevronLeft, Lock, NotebookPen } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { StaggeredList, StaggerItem, AnimationWrapper } from '@/components/motion';
|
|
import { motion } from 'framer-motion';
|
|
import { fastTransition } from '@/lib/motion';
|
|
import { CourseStatusSkeleton } from '@/components/skeletons';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
type StepState = 'open' | 'locked' | 'done';
|
|
|
|
/**
|
|
* Course Status surface (profile menu → وضعیت دوره).
|
|
* Journey status only — no settings, scores, or assessment results.
|
|
*/
|
|
export default function AccountPage() {
|
|
const { effectiveUserId, isGuest, guestSessionHydrated, user } = useAuth();
|
|
const { progress, loading, permissions } = useLearningJourney(effectiveUserId ?? undefined);
|
|
const { currentSession, isProgramComplete, programProgress, progressLoading } =
|
|
useCurrentSession(effectiveUserId);
|
|
const navigate = useNavigate();
|
|
const usernameLabel = isGuest ? 'مهمان' : user?.email?.trim() || null;
|
|
|
|
if (!guestSessionHydrated || loading || progressLoading) {
|
|
return <CourseStatusSkeleton />;
|
|
}
|
|
|
|
if (!effectiveUserId) {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
const cachedProgress = getProgramProgress(effectiveUserId);
|
|
const effectiveProgramProgress = programProgress ?? cachedProgress;
|
|
const completedSessions = effectiveProgramProgress.completedSessions.length;
|
|
const programDone =
|
|
isProgramComplete ||
|
|
Boolean(effectiveProgramProgress.completedAt) ||
|
|
completedSessions >= PROGRAM_SESSION_COUNT;
|
|
const progressPercent = programDone
|
|
? 100
|
|
: Math.round((completedSessions / PROGRAM_SESSION_COUNT) * 100);
|
|
|
|
const preDone = Boolean(progress?.pre_test_completed_at);
|
|
const reachedFinalSession =
|
|
Boolean(progress?.training_completed_at) ||
|
|
completedSessions >= PROGRAM_SESSION_COUNT - 1 ||
|
|
programDone;
|
|
const trainingDone = Boolean(progress?.training_completed_at) || programDone;
|
|
const postDone = Boolean(progress?.post_test_completed_at);
|
|
|
|
const displaySessionOrder =
|
|
currentSession?.order ??
|
|
(programDone
|
|
? PROGRAM_SESSION_COUNT
|
|
: Math.max(effectiveProgramProgress.currentSessionOrder || 1, 1));
|
|
|
|
const preState: StepState = preDone ? 'done' : 'open';
|
|
const trainingState: StepState = !permissions.canOpenTraining
|
|
? 'locked'
|
|
: trainingDone
|
|
? 'done'
|
|
: 'open';
|
|
const postState: StepState = postDone
|
|
? 'done'
|
|
: permissions.canTakePostTest || (reachedFinalSession && preDone && !postDone)
|
|
? 'open'
|
|
: 'locked';
|
|
|
|
return (
|
|
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 px-4 py-6 transition-colors duration-300 dark:bg-slate-900">
|
|
<div className="mx-auto w-full max-w-md space-y-4">
|
|
<AnimationWrapper immediate className="space-y-1 pb-2">
|
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">وضعیت دوره</h1>
|
|
{usernameLabel && (
|
|
<p className="text-sm text-body">
|
|
نام کاربری:{' '}
|
|
<span className="font-medium text-foreground" dir="ltr">
|
|
{usernameLabel}
|
|
</span>
|
|
</p>
|
|
)}
|
|
{!isGuest ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="mt-3 w-full justify-between"
|
|
onClick={() => navigate('/assignments')}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<NotebookPen className="h-4 w-4 text-sky-600 dark:text-sky-400" />
|
|
تکالیف من
|
|
</span>
|
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
) : null}
|
|
</AnimationWrapper>
|
|
|
|
<StaggeredList itemCount={3} className="space-y-3" role="region" aria-label="وضعیت دوره">
|
|
<StaggerItem>
|
|
<StatusRow
|
|
title="پیشآزمون"
|
|
state={preState}
|
|
onOpen={() => navigate('/assessment/pre')}
|
|
lockedHint={preDone ? 'انجام شده — قابل تکرار نیست' : undefined}
|
|
>
|
|
{preDone ? (
|
|
<StatusDone label="انجام شده" />
|
|
) : (
|
|
<p className="text-sm text-body">برای شروع مسیر، پیشآزمون را انجام دهید</p>
|
|
)}
|
|
</StatusRow>
|
|
</StaggerItem>
|
|
|
|
<StaggerItem>
|
|
<StatusRow
|
|
title="دوره آموزشی"
|
|
state={trainingState}
|
|
onOpen={() => navigate('/chat')}
|
|
lockedHint={
|
|
trainingDone ? 'دوره تکمیل شده' : 'پس از انجام پیشآزمون فعال میشود'
|
|
}
|
|
>
|
|
{trainingDone ? (
|
|
<StatusDone label="دوره تکمیل شده" />
|
|
) : !permissions.canOpenTraining ? (
|
|
<p className="flex items-start gap-2 text-sm text-body">
|
|
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span>پس از انجام پیشآزمون فعال میشود</span>
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-foreground">
|
|
جلسه {displaySessionOrder} از {PROGRAM_SESSION_COUNT}
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<Progress value={progressPercent} className="h-2.5 flex-1" />
|
|
<span className="min-w-[2.75rem] text-left text-sm font-semibold tabular-nums text-foreground">
|
|
{progressPercent}٪
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</StatusRow>
|
|
</StaggerItem>
|
|
|
|
<StaggerItem>
|
|
<StatusRow
|
|
title="پسآزمون"
|
|
state={postState}
|
|
onOpen={() => navigate('/assessment/post')}
|
|
lockedHint={
|
|
postDone
|
|
? 'انجام شده — قابل تکرار نیست'
|
|
: 'پس از رسیدن به جلسه پایانی فعال میشود'
|
|
}
|
|
>
|
|
{postState === 'done' && <StatusDone label="انجام شده" />}
|
|
{postState === 'open' && (
|
|
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
<span
|
|
className="inline-block h-2 w-2 shrink-0 rounded-full bg-teal-500 dark:bg-teal-400"
|
|
aria-hidden
|
|
/>
|
|
آماده انجام
|
|
</p>
|
|
)}
|
|
{postState === 'locked' && (
|
|
<p className="flex items-start gap-2 text-sm text-body">
|
|
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span>پس از رسیدن به جلسه پایانی فعال میشود</span>
|
|
</p>
|
|
)}
|
|
</StatusRow>
|
|
</StaggerItem>
|
|
</StaggeredList>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusRow({
|
|
title,
|
|
children,
|
|
state,
|
|
onOpen,
|
|
lockedHint,
|
|
}: {
|
|
title: string;
|
|
children: ReactNode;
|
|
state: StepState;
|
|
onOpen: () => void;
|
|
lockedHint?: string;
|
|
}) {
|
|
const isInteractive = state === 'open';
|
|
|
|
const content = (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1 space-y-2 text-right">
|
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
|
{children}
|
|
</div>
|
|
{isInteractive ? (
|
|
<ChevronLeft className="mt-0.5 h-5 w-5 shrink-0 text-sky-600 dark:text-sky-400" aria-hidden />
|
|
) : (
|
|
<Lock
|
|
className={cn(
|
|
'mt-0.5 h-4 w-4 shrink-0',
|
|
state === 'done' ? 'text-emerald-600/80 dark:text-emerald-400/70' : 'text-muted-foreground'
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (isInteractive) {
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
onClick={onOpen}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
transition={fastTransition}
|
|
className={cn(
|
|
'status-card w-full rounded-2xl px-4 py-4 text-right transition-all duration-200',
|
|
'border border-slate-200/90 bg-gradient-to-b from-white to-slate-50/80',
|
|
'hover:border-sky-300/80 hover:from-white hover:to-sky-50/40',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40',
|
|
'dark:border-slate-700/50 dark:from-slate-800/90 dark:to-slate-900/80',
|
|
'dark:hover:border-slate-600 dark:hover:from-slate-800 dark:hover:to-slate-800/90'
|
|
)}
|
|
aria-label={`${title} — باز کردن`}
|
|
>
|
|
{content}
|
|
</motion.button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'status-card w-full rounded-2xl px-4 py-4 opacity-90',
|
|
'border border-slate-200/80 bg-gradient-to-b from-white to-slate-50/70',
|
|
'dark:border-slate-700/50 dark:from-slate-800/70 dark:to-slate-900/60'
|
|
)}
|
|
aria-disabled="true"
|
|
title={lockedHint}
|
|
>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusDone({ label }: { label: string }) {
|
|
return (
|
|
<p className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
<Check className="h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
|
|
{label}
|
|
</p>
|
|
);
|
|
}
|