import { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Brain, Check, ChevronLeft, Coins, Compass, Heart, Infinity as InfinityIcon, Lightbulb, Ruler, Sparkles, Star, Target, ThumbsUp, X, Zap, type LucideIcon } from 'lucide-react'; import { api, ApiError, type AnswerResult, type Hearts, type Question } from '@/api'; import { useAppData } from '@/state/AppData'; import { fetchHome } from '@/state/subject'; import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum'; import { useNav } from '@/nav/Navigator'; import { newIdempotencyKey } from '@/api/http'; import { SeyarMascot } from '@/mascots/SeyarMascot'; import type { SeyarMood } from '@/mascots/seyar'; import { NoHeartsScreen } from '@/components/NoHeartsScreen'; import { Sheet } from '@/components/Sheet'; import { useToast } from '@/components/Toast'; import { ErrorState, InlineError, LoadingState, PrimaryButton, SecondaryButton, Screen } from '@/components/ui'; import { friendlyError } from '@/lib/errors'; import { toFa } from '@/lib/format'; import { MathText } from '@/lib/MathText'; import { Diagram } from '@/diagram'; import { FeedbackBar } from '@/components/FeedbackBar'; import { sounds } from '@/lib/sound'; import { useGateHold } from '@/lib/useAsync'; import { StreakCelebration, StreakFlame } from '@/components/StreakFlame'; import { useCompanion } from '@/state/companion'; type Phase = | { kind: 'loading' } | { kind: 'error'; error: unknown } | { kind: 'playing' } | { kind: 'noHearts'; nextRefillAt: string | null; fullRefillAt: string | null } | { kind: 'finishing' } | { kind: 'finishError'; error: unknown } | { kind: 'result'; result: QuizResult }; /** نتیجه‌ی مشترکِ پایانِ درس و پایانِ جزیره. */ 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, summary, refreshHome, refreshSummary } = useAppData(); const look = useCompanion(); // زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود. const streakBefore = useRef(summary?.streak ?? null); const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null); const nav = useNav(); const toast = useToast(); const [phase, setPhase] = useState({ kind: 'loading' }); const [questions, setQuestions] = useState([]); const nodeMode = lessonId === undefined; const [hearts, setHearts] = useState(null); const [index, setIndex] = useState(0); const [selected, setSelected] = useState(null); const [answer, setAnswer] = useState(null); const [submitting, setSubmitting] = useState(false); const [correctCount, setCorrectCount] = useState(0); const [confirmExit, setConfirmExit] = useState(false); const [reload, setReload] = useState(0); // پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش). const [combo, setCombo] = useState(0); useGateHold(phase.kind === 'loading'); // یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود. const answerKeys = useRef(new Map()); const completeKey = useRef(newIdempotencyKey()); 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; const unlimited = hearts?.isUnlimited ?? false; const heartsLeft = hearts?.currentHearts ?? 5; const outOfHearts = !unlimited && hearts !== null && heartsLeft <= 0; useEffect(() => { let alive = true; setPhase({ kind: 'loading' }); const load: Promise = 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); setHearts(h); setIndex(0); setSelected(null); setAnswer(null); setCorrectCount(0); setCombo(0); answerKeys.current.clear(); completeKey.current = newIdempotencyKey(); if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt }); else setPhase({ kind: 'playing' }); }) .catch((error: unknown) => { if (!alive) return; // فصلِ غیرِ اول بی‌اشتراک (۴۰۲)؛ صفحه‌ی خطا معنا ندارد، کاربر یک‌راست به اشتراک‌ها می‌رود. if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription' }); else setPhase({ kind: 'error', error }); }); return () => { alive = false; }; }, [lessonId, nodeId, nodeMode, reload]); /** انتخاب فقط گزینه را آبی می‌کند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمی‌شود و می‌شود عوضش کرد. */ function choose(optionId: string) { if (!question || answer || submitting) return; if (optionId !== selected) sounds.select(); setSelected(optionId); } async function check() { if (!question || selected === null || answer || submitting) return; const optionId = selected; setSubmitting(true); let key = answerKeys.current.get(question.id); if (!key) { key = newIdempotencyKey(); answerKeys.current.set(question.id, key); } try { const result = await api.lesson.answer(question.id, optionId, key); setAnswer(result); setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h)); if (result.correct) { setCorrectCount((c) => c + 1); setCombo(combo + 1); if (look.char === 'delta' && combo + 1 >= 2) sounds.combo(); sounds.correct(look.pitch); } else { setCombo(0); sounds.wrong(); } } catch (err) { // گزینه انتخاب‌شده می‌ماند تا کاربر دوباره «بررسی» بزند. if (err instanceof ApiError && err.code === 'no_hearts') { setPhase({ kind: 'noHearts', nextRefillAt: (err.body.nextRefillAt as string | null) ?? null, fullRefillAt: (err.body.fullRefillAt as string | null) ?? null, }); } else { toast(friendlyError(err, 'جوابت ثبت نشد، دوباره بزن'), 'error'); } } finally { setSubmitting(false); } } async function next() { if (outOfHearts && !isLast) { const h = await api.hearts().catch(() => null); setPhase({ kind: 'noHearts', nextRefillAt: h?.nextRefillAt ?? hearts?.nextRefillAt ?? null, fullRefillAt: h?.fullRefillAt ?? hearts?.fullRefillAt ?? null, }); return; } if (!isLast) { setIndex((i) => i + 1); setSelected(null); setAnswer(null); return; } await finish(); } async function finish() { setPhase({ kind: 'finishing' }); try { 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 || look.char === 'tedi') { const colors = [look.color, look.light, '#FFD23F', '#ffffff']; void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: look.char === 'tedi' ? 140 : 90, spread: look.char === 'tedi' ? 100 : 70, origin: { y: 0.35 }, colors }), ); } void refreshHome(); void refreshSummary(); const before = streakBefore.current; if (before && !before.activeToday) { api.summary() .then((fresh) => { if (fresh.streak.activeToday) setStreakUp({ from: before.current, to: fresh.streak.current }); }) .catch((err: unknown) => console.warn('streak check after finish failed', err)); } } catch (error) { // سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار. if (error instanceof ApiError && error.code === 'no_hearts') { setPhase({ kind: 'noHearts', nextRefillAt: (error.body.nextRefillAt as string | null) ?? null, fullRefillAt: (error.body.fullRefillAt as string | null) ?? null, }); return; } setPhase({ kind: 'finishError', error }); } } /** * قلب برگشت: آزمون از همان سوال ادامه می‌یابد. قبلاً کلِ آزمون از نو بار می‌شد و سوال و «درست‌ها» صفر می‌شدند؛ * دانش‌آموزی که وسطِ جزیره قلبش تمام شده بود بعد از ساعت‌ها انتظار باید سوال‌های جواب‌داده را دوباره می‌زد. */ function resume() { if (!questions.length) { setReload((n) => n + 1); return; } api.hearts().then( (h) => { setHearts(h); if (!h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt }); else setPhase({ kind: 'playing' }); }, (err: unknown) => toast(friendlyError(err, 'وضعیت قلب‌ها معلوم نشد، دوباره بزن'), 'error'), ); } const askWhy = () => nav.push({ name: 'ask', lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined), questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟', }); /* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */ if (phase.kind === 'loading') return ; if (phase.kind === 'error') { return (
setReload((n) => n + 1)} />
برگشت
); } if (phase.kind === 'noHearts') { return ; } if (phase.kind === 'result') return ; if (phase.kind === 'finishing' || phase.kind === 'finishError') { return (
{phase.kind === 'finishing' ? (

دارم نتیجه‌ت رو ثبت می‌کنم…

) : ( <> نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)} void finish()}>دوباره ثبت کن بی‌خیال، برگرد )}
); } if (!question) { return (
برگشت
); } /* ─── خودِ بازی ───────────────────────────────────────────────────────── */ const mood: SeyarMood = !answer ? 'idle' : answer.correct ? 'excited' : outOfHearts ? 'depressed' : 'sad'; const bubble = !answer ? selected === null ? 'جواب درست رو انتخاب کن!' : 'مطمئنی؟ «بررسی» رو بزن.' : answer.correct ? combo >= 2 ? `${toFa(combo)} تا پشت هم درست! داری عالی پیش می‌ری` : PRAISE[index % PRAISE.length] : outOfHearts ? 'قلب‌هات تموم شد؛ یه کم استراحت کنیم.' : NUDGE[index % NUDGE.length]; const shortOptions = question.options.every((o) => o.value.length <= 14); const optionClass = (id: string) => { if (!answer) return selected === id ? 'border-[#84D8FF] bg-[#DDF4FF] text-[#1581BA]' : 'border-line bg-white text-ink'; if (id === answer.correctOptionId) return 'border-green-400 bg-[#e5fdf0] text-green-700'; if (id === selected) return 'border-red-300 bg-[#fff0f0] text-red-500'; return 'border-line bg-white text-faint'; }; return (
{/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */} {toFa(index + 1)} از {toFa(questions.length)}
{bubble} {answer && } {answer?.correct && answer.xpEarned > 0 && ( +{toFa(answer.xpEarned)} )}
سوال {toFa(index + 1)}

{question.figure ?
: null}
{question.options.map((option) => ( choose(option.id)} disabled={submitting || answer !== null} aria-pressed={selected === option.id} whileTap={{ scale: answer ? 1 : 0.95 }} 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)}`} > {answer && option.id === answer.correctOptionId && } {answer && option.id === selected && !answer.correct && } ))}
{answer && !answer.correct && look.char === 'pi' && ( π {PI_TIPS[index % PI_TIPS.length]} )}
{answer ? ( <> {!answer.correct && ( )} void next()}> {isLast ? 'پایان تمرین' : 'ادامه'} ) : ( void check()}> بررسی )}
setConfirmExit(false)} title="از تمرین بیرون بری؟">

جواب‌هایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.

setConfirmExit(false)}>ادامه می‌دم nav.leave()}>خروج
); } function HeartsRow({ hearts }: { hearts: Hearts | null }) { if (hearts?.isUnlimited) { return (
قلب نامحدود
); } const max = hearts?.maxHearts ?? 5; const current = hearts?.currentHearts ?? max; return (
{Array.from({ length: max }, (_, i) => ( {i < current && ( )} ))}
); } function Result({ result, total, correct, lessonId, nodeId, color, streakUp, }: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) { const nav = useNav(); const { companion, profile } = useAppData(); const [loadingNext, setLoadingNext] = useState(false); // بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحه‌ی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخاب‌شده. const [streakThen, setStreakThen] = useState<(() => void) | null>(null); const then = (go: () => void) => (streakUp ? setStreakThen(() => go) : go()); if (streakThen && streakUp) return ; 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 fetchHome(profile.gradeId); 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 }); } else { nav.back(); } } catch { nav.back(); } } return (
= 2 ? 'excited' : 'happy'} size={120} />
{[1, 2, 3].map((s) => ( ))}

{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}

{nodeMode &&

این جزیره رو تموم کردی

}

{toFa(correct)} جواب درست از {toFa(total)} سوال

{/* نمره‌ی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهش‌های ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */} {result.stars <= 1 && (

اشتباه‌ها همون جاهایی‌ان که مغزت داره یاد می‌گیره. هر وقت خواستی از «درس‌ها» دوباره مرورش کن.

)}
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمی‌شود. */} {(result.xpEarned > 0 || result.coinsEarned > 0) &&
+{toFa(result.xpEarned)} امتیاز
+{toFa(result.coinsEarned)} سکه
} then(() => void goNext())}> {nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} {nodeMode && nextNode && ( )}
); } /** * پیامِ بعد از جواب. یک جمله‌ی ثابت بعد از چند بار دیگر خوانده نمی‌شود؛ چند تا به نوبت. برای جوابِ غلط * تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند. */ const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی']; const NUDGE = ['نزدیک بودی! جواب درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جواب درست سبز شده.']; /** «راهنمای مهربون»ِ پی: نکته‌ی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */ const PI_TIPS = [ 'صورت سوال رو یه بار دیگه آروم بخون؛ کلمه «نیست» یا «بیشترین» زیاد جا می‌مونه.', 'علامت منفی‌ها رو یه بار دیگه چک کن.', 'اول داخل پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.', 'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.', ]; /** * ویژگیِ همراه کنارِ نوارِ پیشرفتِ آزمون: سیگما جمعِ جوابای درست (Σ)، دلتا شعله‌ی جوابای پشتِ هم. پی و امگا * ویژگی‌شان جای دیگری است (نکته بعد از غلط، جشنِ پایان)، پس این‌جا چیزی نشان نمی‌دهند. */ function TraitChip({ char, color, correct, combo }: { char: string; color: string; correct: number; combo: number }) { if (char === 'sigma') { return ( Σ {toFa(correct)} ); } if (char === 'delta' && combo >= 2) { return ( ×{toFa(combo)} ); } return null; } /** آیکنِ کوچکِ بعد از جواب (همان خانواده‌ی آیکنِ اپ، نه ایموجیِ کیبورد)؛ هر سوال یکی از مجموعه، با یک تکانِ کوتاه. */ const WIN: [LucideIcon, string][] = [[Brain, '#9B7BFF'], [Zap, '#E8A400'], [Target, '#ED6956'], [ThumbsUp, '#3E9E73'], [Sparkles, '#4C5FD5']]; const MISS: [LucideIcon, string][] = [[Lightbulb, '#E8A400'], [Ruler, '#4C5FD5'], [Compass, '#9B7BFF']]; function Reaction({ correct, seed }: { correct: boolean; seed: number }) { const set = correct ? WIN : MISS; const [Icon, color] = set[seed % set.length]; return ( ); }