forked from Tadrisino/MathApp_frontend
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:
@@ -7,6 +7,7 @@ import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
|
||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Screen, ScreenHeader } from '@/components/ui';
|
||||
import { Diagram } from '@/components/Diagram';
|
||||
import { friendlyError } from '@/lib/errors';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
@@ -39,7 +40,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
askStore.setHistory(
|
||||
r.messages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
|
||||
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
|
||||
),
|
||||
)
|
||||
.catch(() => askStore.reset(null));
|
||||
@@ -85,6 +86,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
messageId: done.messageId,
|
||||
usedChunks: done.usedChunks,
|
||||
followUps: done.suggestedFollowUps,
|
||||
diagram: done.diagram ?? null,
|
||||
});
|
||||
askStore.setConversation(done.conversationId);
|
||||
setQuota(done.remainingQuota);
|
||||
@@ -188,7 +190,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
|
||||
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
|
||||
const mine = role === 'user';
|
||||
return (
|
||||
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
@@ -203,6 +205,7 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -251,7 +254,9 @@ function Message({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Bubble role="assistant">{message.text}</Bubble>
|
||||
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
|
||||
{message.text}
|
||||
</Bubble>
|
||||
<div className="flex flex-wrap items-center gap-2 pr-10">
|
||||
{message.failedQuestion ? (
|
||||
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
|
||||
title: 'از پی بپرس',
|
||||
desc: 'من پیام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدمبهقدم توضیح میدم.',
|
||||
},
|
||||
{
|
||||
char: 'tedi', mood: 'excited', bg: '#D9820B',
|
||||
sub: 'چهار همراه، یکی مالِ تو',
|
||||
title: 'همراهت رو انتخاب کن',
|
||||
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراههای تازه باز میشن. از صفحهی «من» هر کدوممون رو خواستی کنارت میذاری.',
|
||||
},
|
||||
];
|
||||
|
||||
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api';
|
||||
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
@@ -23,16 +23,35 @@ type Phase =
|
||||
| { kind: 'noHearts'; nextRefillAt: string | null }
|
||||
| { kind: 'finishing' }
|
||||
| { kind: 'finishError'; error: unknown }
|
||||
| { kind: 'result'; result: LessonComplete };
|
||||
| { kind: 'result'; result: QuizResult };
|
||||
|
||||
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
|
||||
/** نتیجهی مشترکِ پایانِ درس و پایانِ جزیره. */
|
||||
interface QuizResult {
|
||||
stars: number;
|
||||
xpEarned: number;
|
||||
coinsEarned: number;
|
||||
/** فقط در آزمونِ جزیره. */
|
||||
nextNodeId?: string | null;
|
||||
}
|
||||
|
||||
type PlayQuestion = Question & { lessonId?: string };
|
||||
|
||||
/**
|
||||
* تمرین: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه.
|
||||
*
|
||||
* دو حالت دارد. با `lessonId` تمرینِ همان یک درس است (هر درس معمولاً فقط یک
|
||||
* سوال دارد). بدونِ آن آزمونِ کلِ جزیره است (`/nodes/{id}/quiz`، ۴ سوال از
|
||||
* درسهای مختلف) که با تمام شدنش جزیره تمام و جزیرهی بعدی باز میشود؛ مسیرِ
|
||||
* اصلیِ بازی همین است.
|
||||
*/
|
||||
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
|
||||
const { home, companion, refreshHome, refreshSummary } = useAppData();
|
||||
const nav = useNav();
|
||||
const toast = useToast();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [questions, setQuestions] = useState<PlayQuestion[]>([]);
|
||||
const nodeMode = lessonId === undefined;
|
||||
const [hearts, setHearts] = useState<Hearts | null>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -47,7 +66,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
const answerKeys = useRef(new Map<string, string>());
|
||||
const completeKey = useRef(newIdempotencyKey());
|
||||
|
||||
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
|
||||
const ref = findNode(home, nodeId) ?? (lessonId !== undefined ? findNodeOfLesson(home, lessonId) : null);
|
||||
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
|
||||
const question = questions[index];
|
||||
const isLast = index === questions.length - 1;
|
||||
@@ -58,7 +77,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setPhase({ kind: 'loading' });
|
||||
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)])
|
||||
const load: Promise<PlayQuestion[]> = nodeMode
|
||||
? api.nodeQuiz(nodeId ?? '').then((r) => r.questions)
|
||||
: api.lesson.questions(lessonId);
|
||||
Promise.all([load, api.hearts().catch(() => null)])
|
||||
.then(([qs, h]) => {
|
||||
if (!alive) return;
|
||||
setQuestions(qs);
|
||||
@@ -76,7 +98,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lessonId, reload]);
|
||||
}, [lessonId, nodeId, nodeMode, reload]);
|
||||
|
||||
async function choose(optionId: string) {
|
||||
if (!question || selected !== null || submitting) return;
|
||||
@@ -127,11 +149,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
async function finish() {
|
||||
setPhase({ kind: 'finishing' });
|
||||
try {
|
||||
const result = await api.lesson.complete(
|
||||
lessonId,
|
||||
{ correctCount, totalQuestions: questions.length },
|
||||
completeKey.current,
|
||||
);
|
||||
const payload = { correctCount, totalQuestions: questions.length };
|
||||
const result: QuizResult = nodeMode
|
||||
? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
|
||||
: await api.lesson.complete(lessonId, payload, completeKey.current);
|
||||
setPhase({ kind: 'result', result });
|
||||
sounds.finish();
|
||||
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
|
||||
@@ -143,7 +164,12 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
}
|
||||
|
||||
const askWhy = () =>
|
||||
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' });
|
||||
nav.push({
|
||||
name: 'ask',
|
||||
lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined),
|
||||
questionId: question?.id,
|
||||
prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟',
|
||||
});
|
||||
|
||||
/* ─── حالتهای غیرِ بازی ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -159,7 +185,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
);
|
||||
}
|
||||
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />;
|
||||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
|
||||
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||||
return (
|
||||
<Screen>
|
||||
@@ -182,7 +208,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
return (
|
||||
<Screen>
|
||||
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
||||
<ErrorState text="این درس هنوز سوالی نداره" />
|
||||
<ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
|
||||
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
||||
</div>
|
||||
</Screen>
|
||||
@@ -257,7 +283,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
onClick={() => void choose(option.id)}
|
||||
disabled={selected !== null && !answer}
|
||||
whileTap={{ scale: selected === null ? 0.95 : 1 }}
|
||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
|
||||
>
|
||||
<span dir="auto">{option.value}</span>
|
||||
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
|
||||
@@ -281,7 +307,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
|
||||
</div>
|
||||
|
||||
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
|
||||
<p className="mb-5 text-sm leading-7 text-muted">جوابهایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تمومشده حساب نمیشه.</p>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
جوابهایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تمومشده حساب نمیشه.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه میدم</PrimaryButton>
|
||||
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
|
||||
@@ -346,17 +374,25 @@ function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
|
||||
|
||||
function Result({
|
||||
result, total, correct, lessonId, nodeId, color,
|
||||
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) {
|
||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
const [loadingNext, setLoadingNext] = useState(false);
|
||||
|
||||
const nodeMode = lessonId === undefined;
|
||||
const nextNode = result.nextNodeId ?? null;
|
||||
|
||||
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخهی تازهی نقشه میخوانیم.
|
||||
async function goNext() {
|
||||
if (nodeMode) {
|
||||
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
|
||||
else nav.back();
|
||||
return;
|
||||
}
|
||||
setLoadingNext(true);
|
||||
try {
|
||||
const fresh = await api.home();
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId);
|
||||
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
|
||||
const next = ref ? nextLessonOf(ref.node) : null;
|
||||
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
|
||||
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
|
||||
@@ -381,6 +417,7 @@ function Result({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
|
||||
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</p>}
|
||||
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
@@ -396,8 +433,13 @@ function Result({
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||
ادامه <ChevronLeft size={18} />
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
{nodeMode && nextNode && (
|
||||
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||||
فعلاً بسه، برگردیم به نقشه
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
|
||||
import { api, type HomeNode } from '@/api';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
|
||||
/** نقشهی جزیرههای یک فصل، مثلِ مسیرِ دولینگو. */
|
||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { home, homeError, refreshHome, companion } = useAppData();
|
||||
const nav = useNav();
|
||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||
const found = findUnit(home, unitId);
|
||||
|
||||
@@ -32,11 +28,10 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { unit, unitIndex } = found;
|
||||
const color = unitColor(unitIndex);
|
||||
const progress = unitProgress(unit);
|
||||
const points = zigzagPositions(unit.nodes.length, 125, 90);
|
||||
const height = points.length ? points[points.length - 1].y + 150 : 400;
|
||||
const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null;
|
||||
|
||||
return (
|
||||
<Screen className="bg-[#f7f8ff]">
|
||||
<Screen className="bg-page">
|
||||
<ScreenHeader
|
||||
title={unit.title}
|
||||
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
|
||||
@@ -53,160 +48,29 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto pb-6 pt-10">
|
||||
{unit.nodes.length === 0 ? (
|
||||
<EmptyState text="این فصل هنوز جزیرهای نداره" />
|
||||
) : (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
|
||||
{unit.nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
const current = !locked && !done;
|
||||
const Icon = iconFor(node.icon, i);
|
||||
return (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<div
|
||||
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg"
|
||||
style={{ color, borderColor: color }}
|
||||
>
|
||||
{node.title}
|
||||
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity }}
|
||||
className="absolute inset-[-13px] rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمامشده)' : ''}`}
|
||||
onClick={() => setOpenNode(node)}
|
||||
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
|
||||
style={{
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
|
||||
border: current ? `4px solid ${color}` : 'none',
|
||||
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
|
||||
}}
|
||||
>
|
||||
{locked ? (
|
||||
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
|
||||
) : done ? (
|
||||
<Check size={31} color="white" strokeWidth={3.5} />
|
||||
) : (
|
||||
<Icon size={28} style={{ color }} strokeWidth={2.5} />
|
||||
)}
|
||||
</button>
|
||||
{!current && (
|
||||
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
|
||||
{node.title}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
|
||||
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||
<>
|
||||
<IslandPath
|
||||
nodes={unit.nodes}
|
||||
color={color}
|
||||
currentId={current?.id ?? null}
|
||||
onOpen={setOpenNode}
|
||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||
/>
|
||||
<div className="mt-6 flex flex-col items-center">
|
||||
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
||||
<Sparkles size={20} style={{ color }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||
<IslandSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** برگهی جزیره: درسهایش، و دکمهی شروعِ درسِ بعدی. */
|
||||
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||
const nav = useNav();
|
||||
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
|
||||
const next = node ? nextLessonOf(node) : null;
|
||||
const locked = node?.status === 'locked';
|
||||
|
||||
const open = (lessonId: number) => {
|
||||
if (!node) return;
|
||||
onClose();
|
||||
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||
};
|
||||
|
||||
// فقط درسهایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
|
||||
const playable = new Set(node?.lessonIds ?? []);
|
||||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||
const doneCount = list.filter((l) => l.done).length;
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
|
||||
{locked ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{lessons.loading ? (
|
||||
<div className="py-6 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||
) : lessons.error ? (
|
||||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
|
||||
</p>
|
||||
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{list.map((lesson) => {
|
||||
const isNext = Number(lesson.id) === next;
|
||||
return (
|
||||
<li key={lesson.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(Number(lesson.id))}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
|
||||
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
|
||||
}`}
|
||||
style={isNext ? { borderColor: color } : undefined}
|
||||
>
|
||||
{lesson.done ? (
|
||||
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||
) : isNext ? (
|
||||
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
|
||||
) : (
|
||||
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{next != null && (
|
||||
<PrimaryButton color={color} onClick={() => open(next)}>
|
||||
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user