- Every screen but home is its own chunk and preloads when the browser is
idle; React and motion get their own long-cached chunks. The app entry
goes from 505 kB (160 kB gzip) to 85 kB (27 kB gzip).
- Quiz prompts, options and lesson examples go through MathText, so
'10 + 3 - 7 - 2' reads from the left; an unpaired bracket at the edge of
a math run stays with the Persian sentence.
- Support lists tickets from GET /support/tickets instead of this
browser's localStorage; a ticket's messages load when it is opened.
- '·' next to a Persian zero read as another digit ('فصلِ ۱ · ۰' looked
like ۱۰۰); a thin bar separates them now. Notification bodies show
Persian digits.
- A root or vertex the model already marked with coordinates is no longer
labelled a second time on top of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
4.9 KiB
TypeScript
97 lines
4.9 KiB
TypeScript
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="max-w-[130px] truncate text-xs font-black 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>
|
||
);
|
||
}
|