Lessons tab replaces the map tab: chapter > island > lesson list with the island quiz beside it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:46:38 +03:30
parent 838049c469
commit 09ca1a270a
7 changed files with 161 additions and 105 deletions

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { BookOpen, Brain, CheckCircle2, ChevronDown, Circle, Lock, Play } from 'lucide-react';
import { api, type HomeNode, type HomeUnit } from '@/api';
import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
/**
* تبِ «درس‌ها»: فصل ← جزیره ← درس، برای کسی که می‌خواهد اول بخواند.
* آزمونِ هر جزیره همین‌جا کنارِ درس‌هایش است؛ نقشه‌ی بازی‌وار همان خانه است.
* فقط درس‌های سوال‌دار (lessonIds) فهرست می‌شوند، مثلِ برگه‌ی جزیره.
*/
export function LessonsScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav();
const units = home?.units ?? [];
const current = findContinue(home);
const [openUnit, setOpenUnit] = useState<string | null>(current?.unit.id ?? null);
const [openNode, setOpenNode] = useState<string | null>(current?.node.id ?? null);
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title="درس‌ها"
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
color="#2FB36B"
mascot={{ char: 'sigma', mood: 'happy' }}
/>
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-4">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم درس‌ها رو میارم…" />
) : units.length === 0 ? (
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : (
<div className="flex flex-col gap-3">
{units.map((unit, i) => (
<UnitCard
key={unit.id}
unit={unit}
color={unitColor(i)}
open={openUnit === unit.id}
onToggle={() => setOpenUnit(openUnit === unit.id ? null : unit.id)}
openNode={openNode}
onToggleNode={(id) => setOpenNode(openNode === id ? null : id)}
/>
))}
<button
type="button"
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"
>
<Brain size={22} className="flex-shrink-0 text-coral" />
<span className="text-sm leading-6 text-body">یه جایی از درس رو نفهمیدی؟ <b className="text-ink">از دستیار بپرس</b></span>
</button>
</div>
)}
</div>
</Screen>
);
}
function UnitCard({
unit, color, open, onToggle, openNode, onToggleNode,
}: {
unit: HomeUnit; color: string; open: boolean; onToggle: () => void; openNode: string | null; onToggleNode: (id: string) => void;
}) {
const p = unitProgress(unit);
return (
<div className="overflow-hidden rounded-2xl border bg-white" style={{ borderColor: open ? color : '#e6e6f0' }}>
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-4 py-3.5 text-right">
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}22`, color }}>
<BookOpen size={20} />
</span>
<span className="flex-1">
<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 block text-[11px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره تموم شده</span>
</span>
<ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<ul className="flex flex-col gap-1.5 border-t border-line px-3 py-3">
{unit.nodes.map((node) => (
<NodeRow key={node.id} node={node} color={color} open={openNode === node.id} onToggle={() => onToggleNode(node.id)} />
))}
</ul>
)}
</div>
);
}
function NodeRow({ node, color, open, onToggle }: { node: HomeNode; color: string; open: boolean; onToggle: () => void }) {
const nav = useNav();
const locked = node.status === 'locked';
const done = node.status === 'completed';
const lessons = useAsync(() => (open && !locked ? api.nodeLessons(node.id) : Promise.resolve(null)), [node.id, open, locked]);
const playable = new Set(node.lessonIds);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
return (
<li className="rounded-xl bg-[#f7f8ff]">
<button
type="button"
disabled={locked}
onClick={onToggle}
aria-expanded={open}
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 className="flex-1 text-sm font-bold text-ink"><MathText text={node.title} /></span>
{locked ? <span className="text-[11px] text-muted">قفله</span> : <ChevronDown size={16} className={`text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
</button>
{open && !locked && (
<div className="px-3 pb-3">
{lessons.loading ? (
<div className="py-3 text-center text-xs text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<ul className="flex flex-col gap-1.5">
{list.map((l) => (
<li key={l.id}>
<button
type="button"
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"
>
{l.done ? <CheckCircle2 size={16} className="flex-shrink-0 text-mint" /> : <Circle size={16} className="flex-shrink-0 text-faint" />}
<span className={`flex-1 ${l.done ? 'text-muted' : 'text-ink'}`}><MathText text={l.title} /></span>
</button>
</li>
))}
</ul>
)}
<button
type="button"
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"
style={{ backgroundColor: color, boxShadow: `0 3px 0 ${color}99` }}
>
<Play size={15} fill="white" /> {done ? 'دوباره تمرین کن' : 'آزمونِ این جزیره'}
</button>
</div>
)}
</li>
);
}