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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user