From 09ca1a270a85f8c59575e9dac268f635858d3ee4 Mon Sep 17 00:00:00 2001 From: AliReza Date: Thu, 8 Oct 2026 15:46:38 +0330 Subject: [PATCH] Lessons tab replaces the map tab: chapter > island > lesson list with the island quiz beside it Co-Authored-By: Claude Sonnet 5.5 --- src/app/MainApp.tsx | 4 +- src/components/BottomNav.tsx | 4 +- src/nav/Navigator.tsx | 2 +- src/nav/routes.ts | 6 +- src/screens/LessonsScreen.tsx | 152 ++++++++++++++++++++++++++++++++++ src/screens/MapScreen.tsx | 96 --------------------- src/screens/QuizScreen.tsx | 2 +- 7 files changed, 161 insertions(+), 105 deletions(-) create mode 100644 src/screens/LessonsScreen.tsx delete mode 100644 src/screens/MapScreen.tsx diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 03a5aed..cbd54ab 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -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) { function RouteView({ route, onSignedOut }: { route: Route } & Pick) { switch (route.name) { case 'home': return ; - case 'map': return ; + case 'lessons': return ; case 'ask': return ; case 'subscription': return ; case 'profile': return ; diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index f568b08..679f15f 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -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: 'من' }, diff --git a/src/nav/Navigator.tsx b/src/nav/Navigator.tsx index 64cea30..5bd5595 100644 --- a/src/nav/Navigator.tsx +++ b/src/nav/Navigator.tsx @@ -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, diff --git a/src/nav/routes.ts b/src/nav/routes.ts index 3a20a43..aae6b2d 100644 --- a/src/nav/routes.ts +++ b/src/nav/routes.ts @@ -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); diff --git a/src/screens/LessonsScreen.tsx b/src/screens/LessonsScreen.tsx new file mode 100644 index 0000000..5f766ca --- /dev/null +++ b/src/screens/LessonsScreen.tsx @@ -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(current?.unit.id ?? null); + const [openNode, setOpenNode] = useState(current?.node.id ?? null); + + return ( + + {gradeTitle && <>{gradeTitle}}اول بخون، بعد آزمونِ جزیره رو بده} + color="#2FB36B" + mascot={{ char: 'sigma', mood: 'happy' }} + /> + +
+ {!home ? ( + homeError ? void refreshHome()} /> : + ) : units.length === 0 ? ( + + ) : ( +
+ {units.map((unit, i) => ( + setOpenUnit(openUnit === unit.id ? null : unit.id)} + openNode={openNode} + onToggleNode={(id) => setOpenNode(openNode === id ? null : id)} + /> + ))} + +
+ )} +
+
+ ); +} + +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 ( +
+ + {open && ( +
    + {unit.nodes.map((node) => ( + onToggleNode(node.id)} /> + ))} +
+ )} +
+ ); +} + +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 ( +
  • + + {open && !locked && ( +
    + {lessons.loading ? ( +
    دارم درس‌ها رو میارم…
    + ) : lessons.error ? ( + + ) : ( +
      + {list.map((l) => ( +
    • + +
    • + ))} +
    + )} + +
    + )} +
  • + ); +} diff --git a/src/screens/MapScreen.tsx b/src/screens/MapScreen.tsx deleted file mode 100644 index 53b64ad..0000000 --- a/src/screens/MapScreen.tsx +++ /dev/null @@ -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 ( - - {gradeTitle && <>{gradeTitle}}{toFa(overall)}٪ از کلِ مسیر} - color="#91ADC6" - mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }} - > -
    - -
    -
    - -
    - {!home ? ( - homeError ? void refreshHome()} /> : - ) : units.length === 0 ? ( - - ) : ( -
    - (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 ( - - -
    -
    {unit.title}
    -
    {toFa(p.done)} از {toFa(p.total)} جزیره
    -
    -
    - ); - })} -
    - = 100 ? 'excited' : 'happy'} size={72} bob /> -
    - {overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همین‌جا منتظرتم'} -
    -
    -
    - )} -
    -
    - ); -} diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index 477e47c..213f4a3 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -439,7 +439,7 @@ function Result({ {nodeMode && nextNode && ( )}