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:
@@ -28,7 +28,7 @@ const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScree
|
||||
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
|
||||
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
|
||||
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
|
||||
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen');
|
||||
const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
|
||||
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
|
||||
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
|
||||
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
|
||||
@@ -98,7 +98,7 @@ function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||||
switch (route.name) {
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'map': return <MapScreen />;
|
||||
case 'lessons': return <LessonsScreen />;
|
||||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||||
case 'subscription': return <SubscriptionScreen />;
|
||||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Brain, ShoppingBag, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import type { TabName } from '@/nav/routes';
|
||||
|
||||
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
|
||||
{ id: 'home', icon: Home, label: 'خانه' },
|
||||
{ id: 'map', icon: Map, label: 'نقشه' },
|
||||
{ id: 'lessons', icon: BookOpen, label: 'درسها' },
|
||||
{ id: 'ask', icon: Brain, label: 'دستیار' },
|
||||
{ id: 'subscription', icon: ShoppingBag, label: 'بستهها' },
|
||||
{ id: 'profile', icon: UserRound, label: 'من' },
|
||||
|
||||
@@ -53,7 +53,7 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
||||
const root = stack[0].name;
|
||||
return {
|
||||
route: stack[stack.length - 1],
|
||||
tab: ['home', 'map', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
||||
tab: ['home', 'lessons', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
|
||||
canGoBack: stack.length > 1,
|
||||
push,
|
||||
replace,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** همهی صفحههای اپ و پارامترهایشان. */
|
||||
export type Route =
|
||||
| { name: 'home' }
|
||||
| { name: 'map' }
|
||||
| { name: 'lessons' }
|
||||
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
||||
| { name: 'subscription' }
|
||||
| { name: 'profile' }
|
||||
@@ -20,9 +20,9 @@ export type Route =
|
||||
| { name: 'invoices' }
|
||||
| { name: 'invite' };
|
||||
|
||||
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';
|
||||
export type TabName = 'home' | 'lessons' | 'ask' | 'subscription' | 'profile';
|
||||
|
||||
export const TABS: TabName[] = ['home', 'map', 'ask', 'subscription', 'profile'];
|
||||
export const TABS: TabName[] = ['home', 'lessons', 'ask', 'subscription', 'profile'];
|
||||
|
||||
export function isTab(name: Route['name']): name is TabName {
|
||||
return (TABS as string[]).includes(name);
|
||||
|
||||
152
src/screens/LessonsScreen.tsx
Normal file
152
src/screens/LessonsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
/** نقشهی فصلهای پایه: هر خانه یک فصل است. */
|
||||
export function MapScreen() {
|
||||
const { home, homeError, refreshHome, gradeTitle } = useAppData();
|
||||
const nav = useNav();
|
||||
const units = home?.units ?? [];
|
||||
const points = zigzagPositions(units.length, 150);
|
||||
const height = points.length ? points[points.length - 1].y + 200 : 400;
|
||||
const overall = Math.round(home?.stats.progressPercent ?? 0);
|
||||
|
||||
return (
|
||||
<Screen className="bg-[#f7f8ff]">
|
||||
<ScreenHeader
|
||||
title="نقشهی فصلها"
|
||||
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}{toFa(overall)}٪ از کلِ مسیر</>}
|
||||
color="#91ADC6"
|
||||
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
|
||||
>
|
||||
<div className="mt-4">
|
||||
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!home ? (
|
||||
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصلها رو میارم…" />
|
||||
) : units.length === 0 ? (
|
||||
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
|
||||
) : (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath
|
||||
points={points}
|
||||
height={height}
|
||||
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
|
||||
/>
|
||||
{units.map((unit, i) => {
|
||||
const p = unitProgress(unit);
|
||||
const color = unitColor(i);
|
||||
const Icon = iconFor(unit.nodes[0]?.icon, i);
|
||||
const complete = p.total > 0 && p.done === p.total;
|
||||
return (
|
||||
<motion.div
|
||||
key={unit.id}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={unit.title}
|
||||
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
|
||||
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
|
||||
style={{
|
||||
backgroundColor: complete ? color : '#fff',
|
||||
border: complete ? 'none' : `4px solid ${color}`,
|
||||
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
|
||||
}}
|
||||
>
|
||||
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
|
||||
{!complete && p.done > 0 && (
|
||||
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
|
||||
{toFa(p.percent)}٪
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
|
||||
<div className="line-clamp-2 max-w-[170px] text-xs font-black leading-5 text-ink">{unit.title}</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
|
||||
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
|
||||
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
|
||||
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همینجا منتظرتم'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -439,7 +439,7 @@ function Result({
|
||||
</PrimaryButton>
|
||||
{nodeMode && nextNode && (
|
||||
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
فعلاً بسه، برگردیم به نقشه
|
||||
فعلاً بسه، برگردیم
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user