Files
pwa/src/pages/Account/AccountPage.tsx
T
ar.badieiandCursor 8c37c15697 Fix minimum age 12 and ensure assignment catalog seeds on startup.
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>
2026-09-22 07:24:40 +03:30

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