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

@@ -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>
);