play whole islands, make home the path itself, draw tutor diagrams, add tedi

- Home is now the island path of every unit with one clear Start button on the
  current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
  an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
  fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
  and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:45:57 +03:30
parent b5d97c0cda
commit 355bae7035
17 changed files with 1075 additions and 347 deletions

View File

@@ -1,180 +1,139 @@
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Flame, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData';
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
import { iconFor } from '@/components/NodeIcon';
import { IslandPath } from '@/components/IslandPath';
import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, Screen } from '@/components/ui';
import { shade } from '@/lib/color';
import { toFa } from '@/lib/format';
/**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
* یک دکمه‌ی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/
export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
const nav = useNav();
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
const currentRef = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);
const cont = findContinue(home);
const contLesson = cont ? nextLessonOf(cont.node) : null;
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
const contProgress = cont ? unitProgress(cont.unit) : null;
const openLesson = (quiz = false) => {
if (!cont || contLesson == null) return;
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
};
// بارِ اول تا جزیره‌ی فعلی اسکرول کن، نه هر بار که نقشه تازه می‌شود.
useEffect(() => {
if (!home || scrolled.current) return;
scrolled.current = true;
const frame = requestAnimationFrame(() => currentRef.current?.scrollIntoView({ block: 'center' }));
return () => cancelAnimationFrame(frame);
}, [home]);
const name = profile.name || 'دوستِ من';
const bubble = !home
? 'دارم مسیرت رو میارم…'
: !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.'
: cont
? <>سلام {name}! 👋 نوبتِ جزیره‌ی <b>«{cont.node.title}»</b>ـه. دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی 🎉</>;
const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const },
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
];
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
<div className="relative flex items-center justify-between pt-8">
<div className="min-w-0">
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
<h1 className="mt-0.5 text-xl font-black leading-snug">آماده‌ای ریاضی رو ادامه بدیم؟</h1>
{gradeTitle && (
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Trophy size={18} />
</button>
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
</span>
)}
</button>
</div>
</div>
</header>
<div className="relative z-10 -mt-7 px-4">
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
</span>
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
<span className="mt-1 text-[10px] text-muted">{label}</span>
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
<div className="flex items-center gap-2 pt-4">
{gradeTitle && (
<button type="button" onClick={() => nav.switchTab('profile')} className="rounded-full bg-white/20 px-3 py-1.5 text-xs font-black">
{gradeTitle}
</button>
)}
<div className="mr-auto flex items-center gap-1.5">
{stats.map(({ icon: Icon, value, label, color, fill, to }) => (
<button
key={label}
type="button"
aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })}
className="flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm"
>
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
{toFa(value)}
</button>
))}
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<Bell size={16} />
{unreadCount > 0 && (
<span className="absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
</span>
)}
</button>
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-6 pt-4">
{homeError && !home ? (
<ErrorState error={homeError} onRetry={() => void refreshHome()} />
) : cont && contProgress ? (
<Card className="shadow-md shadow-coral/10">
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
<p className="mt-0.5 truncate text-xs text-muted">
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
</p>
</div>
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
</div>
<div className="mb-3">
<ProgressBar percent={contProgress.percent} color={contColor} />
</div>
<button
type="button"
onClick={() => openLesson()}
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
style={{ backgroundColor: contColor }}
>
<Play size={15} fill="white" /> ادامه بده
</button>
</Card>
) : (
<Card className="flex items-center gap-3 p-5">
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
<p className="text-sm font-medium text-muted">
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همه‌ی جزیره‌های این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
</p>
</Card>
)}
<div className="grid grid-cols-2 gap-3">
<Card onClick={() => nav.switchTab('map')} className="p-4">
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
<Map size={20} className="text-steel" />
</span>
<div className="text-sm font-bold text-ink">نقشه‌ی فصل‌ها</div>
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
</Card>
<button
type="button"
onClick={() => nav.switchTab('ask')}
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
<Brain size={20} className="text-white" />
</span>
<div className="text-sm font-bold text-white">از پی بپرس</div>
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
</button>
<div className="mt-3 flex items-end gap-2">
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
{bubble}
</div>
</div>
</header>
{home && home.units.length > 0 && (
<section>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-black text-ink">فصل‌های {gradeTitle || 'این پایه'}</h2>
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
</div>
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
{home.units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
return (
<button
key={unit.id}
type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
<Icon size={19} style={{ color }} />
</span>
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
<div className="mt-2">
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
) : home.units.length === 0 ? (
<EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
) : (
<div className="flex flex-col gap-2 px-4 pb-10 pt-4">
{home.units.map((unit, i) => {
const color = unitColor(i);
const p = unitProgress(unit);
const isCurrentUnit = cont?.unit.id === unit.id;
return (
<section key={unit.id} aria-label={unit.title}>
<button
type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-bold text-white/80">
فصلِ {toFa(i + 1)} · {toFa(p.done)} از {toFa(p.total)} جزیره
{isCurrentUnit && <span className="mr-1.5 rounded-full bg-white/25 px-2 py-0.5">الان اینجایی</span>}
</div>
</button>
);
})}
</div>
</section>
)}
{cont && contLesson != null && (
<button
type="button"
onClick={() => openLesson(true)}
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
>
<SeyarMascot char="delta" mood="excited" size={64} />
<div className="min-w-0 flex-1">
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
</div>
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
</button>
)}
</div>
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
</div>
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
</button>
<div className="pt-12">
<IslandPath
ref={isCurrentUnit ? currentRef : undefined}
nodes={unit.nodes}
color={color}
currentId={isCurrentUnit ? cont!.node.id : null}
onOpen={(node) => setOpenNode({ node, color })}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
/>
</div>
</section>
);
})}
</div>
)}
</div>
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
</Screen>
);
}