Lessons tab: chapters closed by default, no quiz-button flash while lessons load, state kept on back, continue card and progress

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:00:49 +03:30
parent 09ca1a270a
commit dc1fb15a55

View File

@@ -1,35 +1,54 @@
import { useState } from 'react'; import { useState } from 'react';
import { BookOpen, Brain, CheckCircle2, ChevronDown, Circle, Lock, Play } from 'lucide-react'; import { motion } from 'motion/react';
import { Brain, Check, ChevronDown, Lock, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode, type HomeUnit } from '@/api'; import { api, type HomeNode, type HomeUnit } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
/** /**
* تبِ «درس‌ها»: فصل ← جزیره ← درس، برای کسی که می‌خواهد اول بخواند. * تبِ «درس‌ها»: فصل ← جزیره ← درس. فصل‌ها اول بسته‌اند و بالای صفحه یک کارتِ «ادامه بده» هست.
* آزمونِ هر جزیره همین‌جا کنارِ درس‌هایش است؛ نقشه‌ی بازی‌وار همان خانه است. * بازبودنِ فصل/جزیره در متغیرِ ماژول نگه داشته می‌شود، چون با رفتن به درس و برگشتن صفحه از نو ساخته
* فقط درس‌های سوال‌دار (lessonIds) فهرست می‌شوند، مثلِ برگه‌ی جزیره. * می‌شود (کلیدِ مسیر در MainApp) و کاربر جایش را گم می‌کرد. با بستنِ اپ پاک می‌شود، عمداً.
*/ */
const remembered: { unit: string | null; node: string | null } = { unit: null, node: null };
export function LessonsScreen() { export function LessonsScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData(); const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav(); const nav = useNav();
const units = home?.units ?? []; const units = home?.units ?? [];
const current = findContinue(home); const current = findContinue(home);
const [openUnit, setOpenUnit] = useState<string | null>(current?.unit.id ?? null); const [openUnit, setOpenUnitState] = useState<string | null>(remembered.unit);
const [openNode, setOpenNode] = useState<string | null>(current?.node.id ?? null); const [openNode, setOpenNodeState] = useState<string | null>(remembered.node);
const setOpenUnit = (id: string | null) => { remembered.unit = id; setOpenUnitState(id); };
const setOpenNode = (id: string | null) => { remembered.node = id; setOpenNodeState(id); };
const totalNodes = units.reduce((n, u) => n + u.nodes.length, 0);
const doneNodes = units.reduce((n, u) => n + unitProgress(u).done, 0);
return ( return (
<Screen className="bg-[#f7f8ff]"> <Screen className="bg-page">
<ScreenHeader <ScreenHeader
title="درس‌ها" title="درس‌ها"
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>} subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
color="#2FB36B" color="#2FB36B"
mascot={{ char: 'sigma', mood: 'happy' }} mascot={{ char: 'sigma', mood: 'happy' }}
/> >
{home && totalNodes > 0 && (
<div className="mt-4">
<div className="mb-1.5 flex justify-between text-[11px] text-white/85">
<span>جزیره‌هایی که تموم کردی</span>
<span>{toFa(doneNodes)} از {toFa(totalNodes)}</span>
</div>
<ProgressBar percent={Math.round((doneNodes / totalNodes) * 100)} color="#fff" track="rgba(255,255,255,.25)" height={10} />
</div>
)}
</ScreenHeader>
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4"> <div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
{!home ? ( {!home ? (
@@ -38,23 +57,43 @@ export function LessonsScreen() {
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" /> <EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{current && current.node.nextLessonId != null && (
<button
type="button"
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
style={{ backgroundColor: unitColor(current.unitIndex), boxShadow: `0 4px 0 ${unitColor(current.unitIndex)}99` }}
>
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
<Play size={20} fill="white" />
</span>
<span className="flex-1">
<span className="block text-[11px] font-bold text-white/85">ادامه بده از همین‌جا</span>
<span className="block text-sm font-black leading-6"><MathText text={current.node.title} /></span>
</span>
</button>
)}
{units.map((unit, i) => ( {units.map((unit, i) => (
<UnitCard <UnitCard
key={unit.id} key={unit.id}
index={i}
unit={unit} unit={unit}
color={unitColor(i)} color={unitColor(i)}
currentNodeId={current?.node.id ?? null}
open={openUnit === unit.id} open={openUnit === unit.id}
onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)} onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)}
openNode={openNode} openNode={openNode}
onToggleNode={(id) => setOpenNode(openNode === id ? null : id)} onToggleNode={(id) => setOpenNode(openNode === id ? null : id)}
/> />
))} ))}
<button <button
type="button" type="button"
onClick={() => nav.switchTab('ask')} onClick={() => nav.switchTab('ask')}
className="mt-2 flex items-center gap-3 rounded-2xl border border-line bg-white px-4 py-3 text-right" className="mt-1 flex items-center gap-3 rounded-2xl border border-line bg-white px-4 py-3 text-right"
> >
<Brain size={22} className="flex-shrink-0 text-coral" /> <Brain size={22} className="flex-shrink-0 text-violet" />
<span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span> <span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span>
</button> </button>
</div> </div>
@@ -65,45 +104,74 @@ export function LessonsScreen() {
} }
function UnitCard({ function UnitCard({
unit, color, open, onToggle, openNode, onToggleNode, index, unit, color, currentNodeId, open, onToggle, openNode, onToggleNode,
}: { }: {
unit: HomeUnit; color: string; open: boolean; onToggle: () => void; openNode: string | null; onToggleNode: (id: string) => void; index: number; unit: HomeUnit; color: string; currentNodeId: string | null; open: boolean; onToggle: () => void;
openNode: string | null; onToggleNode: (id: string) => void;
}) { }) {
const p = unitProgress(unit); const p = unitProgress(unit);
const complete = p.total > 0 && p.done === p.total;
const untouched = unit.nodes.every((n) => n.status === 'locked');
const Icon = iconFor(unit.nodes[0]?.icon, index);
return ( return (
<div className="overflow-hidden rounded-2xl border bg-white" style={{ borderColor: open ? color : '#e6e6f0' }}> <motion.div
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-4 py-3.5 text-right"> initial={{ opacity: 0, y: 12 }}
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}22`, color }}> animate={{ opacity: 1, y: 0 }}
<BookOpen size={20} /> transition={{ delay: Math.min(index, 8) * 0.04 }}
className="overflow-hidden rounded-2xl bg-white"
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
>
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
<span
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
>
{complete ? <Check size={26} strokeWidth={3.5} /> : untouched ? <Lock size={22} /> : <Icon size={26} strokeWidth={2.2} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-bold" style={{ color }}>
{complete ? 'تموم شد 🎉' : `${toFa(p.total)} جزیره`}
</span> </span>
<span className="flex-1">
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span> <span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
<span className="mt-1 block"><ProgressBar percent={p.percent} color={color} height={6} /></span> <span className="mt-1 flex items-center gap-2">
<span className="mt-1 block text-[11px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره تموم شده</span> <span className="flex-1"><ProgressBar percent={p.percent} color={color} height={6} /></span>
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
</span>
</span> </span>
<ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} /> <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
</button> </button>
{open && ( {open && (
<ul className="flex flex-col gap-1.5 border-t border-line px-3 py-3"> <ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
{unit.nodes.map((node) => ( {unit.nodes.map((node, ni) => (
<NodeRow key={node.id} node={node} color={color} open={openNode === node.id} onToggle={() => onToggleNode(node.id)} /> <NodeRow
key={node.id}
num={ni + 1}
node={node}
color={color}
isCurrent={node.id === currentNodeId}
open={openNode === node.id}
onToggle={() => onToggleNode(node.id)}
/>
))} ))}
</ul> </ul>
)} )}
</div> </motion.div>
); );
} }
function NodeRow({ node, color, open, onToggle }: { node: HomeNode; color: string; open: boolean; onToggle: () => void }) { function NodeRow({
num, node, color, isCurrent, open, onToggle,
}: { num: number; node: HomeNode; color: string; isCurrent: boolean; open: boolean; onToggle: () => void }) {
const nav = useNav(); const nav = useNav();
const locked = node.status === 'locked'; const locked = node.status === 'locked';
const done = node.status === 'completed'; const done = node.status === 'completed';
const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]); const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]);
const playable = new Set(node.lessonIds); const playable = new Set(node.lessonIds);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? []; const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
const loaded = !lessons.loading && !lessons.error && lessons.data !== null && lessons.data !== undefined;
return ( return (
<li className="rounded-xl bg-[#f7f8ff]"> <li className="rounded-xl border bg-white" style={{ borderColor: isCurrent ? color : '#e9e6f7' }}>
<button <button
type="button" type="button"
disabled={locked} disabled={locked}
@@ -111,40 +179,66 @@ function NodeRow({ node, color, open, onToggle }: { node: HomeNode; color: strin
aria-expanded={open} aria-expanded={open}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-right disabled:opacity-60" className="flex w-full items-center gap-2.5 px-3 py-2.5 text-right disabled:opacity-60"
> >
{locked ? <Lock size={17} className="flex-shrink-0 text-muted" /> : done ? <CheckCircle2 size={17} className="flex-shrink-0 text-mint" /> : <Circle size={17} style={{ color }} className="flex-shrink-0" />} <span
<span className="flex-1 text-sm font-bold text-ink"><MathText text={node.title} /></span> className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
{locked ? <span className="text-[11px] text-muted">قفله</span> : <ChevronDown size={16} className={`text-muted transition-transform ${open ? 'rotate-180' : ''}`} />} style={done ? { backgroundColor: '#5FBF8F', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
>
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold leading-6 text-ink"><MathText text={node.title} /></span>
<span className="block text-[11px] text-muted">
{locked ? 'جزیره‌ی قبلی رو تموم کن تا باز بشه' : `${toFa(node.lessonIds.length)} درس`}
</span>
</span>
{isCurrent && !open && (
<span className="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black text-white" style={{ backgroundColor: color }}>
بعدی
</span>
)}
{!locked && <ChevronDown size={16} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
</button> </button>
{open && !locked && ( {open && !locked && (
<div className="px-3 pb-3"> <div className="px-3 pb-3">
{lessons.loading ? ( {lessons.error ? (
<div className="py-3 text-center text-xs text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} /> <ErrorState error={lessons.error} onRetry={lessons.reload} />
) : !loaded ? (
<div className="flex flex-col gap-1.5" aria-busy="true" aria-label="دارم درس‌ها رو میارم">
{[0, 1, 2].map((k) => <div key={k} className="h-10 animate-pulse rounded-xl bg-soft" />)}
</div>
) : ( ) : (
<>
<ul className="flex flex-col gap-1.5"> <ul className="flex flex-col gap-1.5">
{list.map((l) => ( {list.map((l, li) => (
<li key={l.id}> <li key={l.id}>
<button <button
type="button" type="button"
onClick={() => nav.push({ name: 'lesson', lessonId: Number(l.id), nodeId: node.id })} onClick={() => nav.push({ name: 'lesson', lessonId: Number(l.id), nodeId: node.id })}
className="flex w-full items-center gap-2.5 rounded-xl border border-line bg-white px-3 py-2 text-right text-sm" className="flex w-full items-center gap-2.5 rounded-xl bg-[#f7f8ff] px-3 py-2 text-right text-sm active:scale-[.99]"
> >
{l.done ? <CheckCircle2 size={16} className="flex-shrink-0 text-mint" /> : <Circle size={16} className="flex-shrink-0 text-faint" />} <span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
style={l.done ? { backgroundColor: '#5FBF8F', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
>
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
</span>
<span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span> <span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span>
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
)}
<button <button
type="button" type="button"
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })} onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-sm font-black text-white" className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
style={{ backgroundColor: color, boxShadow: `0 3px 0 ${color}99` }} style={{ backgroundColor: color, boxShadow: `0 3px 0 ${color}99` }}
> >
<Play size={15} fill="white" /> {done ? 'دوباره تمرین کن' : 'آزمونِ این جزیره'} {done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
{done ? 'دوباره تمرین کن' : 'درس‌ها رو خوندی؟ برو آزمون'}
</button> </button>
</>
)}
</div> </div>
)} )}
</li> </li>