forked from Tadrisino/MathApp_frontend
- http: a request whose 401 arrived after another request's refresh had finished refreshed again; it now resends with the new token. A refresh finishing after logout no longer restores tokens. - logout sends the refresh token current at send time (after a 401 -> refresh it had sent the rotated-out one and left the new session alive on the server for 30 days). - Sign-out/sign-in in another browser tab is followed (storage event). - Navigator removes its history entries when the main app unmounts (Back did nothing after logout). - Login: +98 / 0098 / 9xxxxxxxxx phone input (autofill, typing, paste) normalised; incomplete OTP is not sent (each wrong guess burns one of five). - Quiz resumes at the same question after hearts refill instead of restarting; the no-hearts screen re-checks while due (phone clock ahead of the server left it stuck at zero). - Payment return: a pending order is re-checked for about a minute instead of shown as failed; the pay button is reset when the page comes back from bfcache. - Tutor: retry keeps the quiz question id; a network error no longer forgets the conversation; daily quota detected by code ai_daily_quota. - Root error boundary and vite:preloadError reload instead of a white screen; deploy.sh keeps hashed assets of earlier builds for 30 days so open apps can still load their chunks. - Sheet: labelled dialog, focus moves in and back, Escape closes only the top sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
606 lines
30 KiB
TypeScript
606 lines
30 KiB
TypeScript
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<Phase>({ kind: 'loading' });
|
||
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);
|
||
const [answer, setAnswer] = useState<AnswerResult | null>(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<string, string>());
|
||
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<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);
|
||
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 <Screen><LoadingState text="دارم سوالها رو میارم…" /></Screen>;
|
||
if (phase.kind === 'error') {
|
||
return (
|
||
<Screen>
|
||
<div className="flex flex-1 flex-col justify-center">
|
||
<ErrorState error={phase.error} onRetry={() => setReload((n) => n + 1)} />
|
||
<div className="px-8"><SecondaryButton onClick={nav.back}>برگشت</SecondaryButton></div>
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
if (phase.kind === 'noHearts') {
|
||
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={resume} />;
|
||
}
|
||
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
|
||
if (phase.kind === 'finishing' || phase.kind === 'finishError') {
|
||
return (
|
||
<Screen>
|
||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||
<SeyarMascot char={companion} mood={phase.kind === 'finishing' ? 'happy' : 'sad'} size={96} />
|
||
{phase.kind === 'finishing' ? (
|
||
<p className="text-sm text-muted">دارم نتیجهت رو ثبت میکنم…</p>
|
||
) : (
|
||
<>
|
||
<InlineError>نتیجه این تمرین ثبت نشد: {friendlyError(phase.error)}</InlineError>
|
||
<PrimaryButton color={color} onClick={() => void finish()}>دوباره ثبت کن</PrimaryButton>
|
||
<SecondaryButton onClick={nav.back}>بیخیال، برگرد</SecondaryButton>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
if (!question) {
|
||
return (
|
||
<Screen>
|
||
<div className="flex flex-1 flex-col justify-center gap-4 px-8">
|
||
<ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
|
||
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
/* ─── خودِ بازی ───────────────────────────────────────────────────────── */
|
||
|
||
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 (
|
||
<Screen>
|
||
<div className="flex-shrink-0 border-b border-line bg-white px-5 pb-4 pt-safe">
|
||
<div className="flex items-center gap-3 pt-5">
|
||
<button type="button" aria-label="خروج از تمرین" onClick={() => setConfirmExit(true)} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-soft">
|
||
<X size={15} className="text-coral" />
|
||
</button>
|
||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
|
||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
|
||
</div>
|
||
<TraitChip char={look.char} color={look.color} correct={correctCount} combo={combo} />
|
||
<span className="flex-shrink-0 text-left text-xs font-bold text-muted">
|
||
{/* «۱/۴» در اپِ ریاضی کسر خوانده میشد. */}
|
||
{toFa(index + 1)} از {toFa(questions.length)}
|
||
</span>
|
||
</div>
|
||
<HeartsRow hearts={hearts} />
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<AnimatePresence mode="wait">
|
||
<motion.div key={mood} initial={{ scale: 0.7, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.7, opacity: 0 }} className="rounded-3xl border-2 border-line bg-white p-2">
|
||
<SeyarMascot char={companion} mood={mood} size={64} />
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
<div className={`flex-1 rounded-2xl border px-4 py-3 text-xs font-bold leading-6 ${
|
||
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
|
||
}`}>
|
||
{bubble}
|
||
{answer && <Reaction key={`r${question.id}`} correct={answer.correct} seed={index} />}
|
||
{answer?.correct && answer.xpEarned > 0 && (
|
||
<motion.span
|
||
key={`xp${question.id}`}
|
||
initial={{ opacity: 0, y: 8, scale: 0.8 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
|
||
className="mr-2 inline-block rounded-full bg-[#FFF3C4] px-2 py-0.5 text-[11px] font-black text-[#8A5A00]"
|
||
>
|
||
+{toFa(answer.xpEarned)} <Star size={11} className="mb-0.5 inline" fill="#E8A400" color="#E8A400" />
|
||
</motion.span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-3xl border border-line bg-white p-5 shadow-sm">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
|
||
</div>
|
||
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
|
||
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} hideCounts /></div> : null}
|
||
</div>
|
||
|
||
<div className={shortOptions ? 'grid grid-cols-2 gap-2.5' : 'flex flex-col gap-2.5'}>
|
||
{question.options.map((option) => (
|
||
<motion.button
|
||
key={option.id}
|
||
type="button"
|
||
onClick={() => 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)}`}
|
||
>
|
||
<span><MathText text={option.value} /></span>
|
||
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
|
||
{answer && option.id === selected && !answer.correct && <X size={16} className="text-red-400" strokeWidth={3} />}
|
||
</motion.button>
|
||
))}
|
||
</div>
|
||
|
||
<FeedbackBar kind="question" id={question.id} />
|
||
</div>
|
||
|
||
{answer && !answer.correct && look.char === 'pi' && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="mx-4 mb-2 flex items-start gap-2 rounded-2xl bg-[#F5F1FF] px-3 py-2.5 text-xs font-bold leading-6 text-[#5B3FA0]"
|
||
>
|
||
<span className="text-base leading-5">π</span>
|
||
<span>{PI_TIPS[index % PI_TIPS.length]}</span>
|
||
</motion.div>
|
||
)}
|
||
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
|
||
{answer ? (
|
||
<>
|
||
{!answer.correct && (
|
||
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
|
||
<Brain size={16} /> از {look.name} بپرس چرا غلط شد
|
||
</button>
|
||
)}
|
||
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
|
||
{isLast ? 'پایان تمرین' : 'ادامه'} <ChevronLeft size={18} />
|
||
</PrimaryButton>
|
||
</>
|
||
) : (
|
||
<PrimaryButton color={color} disabled={selected === null} loading={submitting} onClick={() => void check()}>
|
||
بررسی
|
||
</PrimaryButton>
|
||
)}
|
||
</div>
|
||
|
||
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
|
||
<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={() => nav.leave()}>خروج</SecondaryButton>
|
||
</div>
|
||
</Sheet>
|
||
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||
if (hearts?.isUnlimited) {
|
||
return (
|
||
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs font-bold text-coral">
|
||
<Heart size={18} fill="#ED6956" /> <InfinityIcon size={18} /> قلب نامحدود
|
||
</div>
|
||
);
|
||
}
|
||
const max = hearts?.maxHearts ?? 5;
|
||
const current = hearts?.currentHearts ?? max;
|
||
return (
|
||
<div className="mt-3 flex items-center justify-center gap-2" aria-label={`${toFa(current)} قلب از ${toFa(max)}`}>
|
||
{Array.from({ length: max }, (_, i) => (
|
||
<span key={i} className="relative h-5 w-5">
|
||
<Heart size={20} className="absolute inset-0 text-[#e5e1f7]" />
|
||
<AnimatePresence>
|
||
{i < current && (
|
||
<motion.span
|
||
className="absolute inset-0"
|
||
exit={{ scale: [1, 1.35, 0], rotate: [0, -22, 18, 0], y: [0, -4, 10], opacity: [1, 1, 0] }}
|
||
transition={{ duration: 0.55 }}
|
||
>
|
||
<Heart size={20} className="text-coral" fill="#ED6956" />
|
||
</motion.span>
|
||
)}
|
||
</AnimatePresence>
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 <StreakCelebration from={streakUp.from} to={streakUp.to} onDone={streakThen} />;
|
||
|
||
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 (
|
||
<Screen>
|
||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-8 text-center">
|
||
<SeyarMascot char={companion} mood={result.stars >= 2 ? 'excited' : 'happy'} size={120} />
|
||
<div className="flex gap-1.5" aria-label={`${toFa(result.stars)} ستاره`}>
|
||
{[1, 2, 3].map((s) => (
|
||
<motion.span key={s} initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2 + s * 0.15, type: 'spring' }}>
|
||
<Star size={40} className={s <= result.stars ? 'text-[#FFC53D]' : 'text-[#e5e1f7]'} fill={s <= result.stars ? '#FFC53D' : '#e5e1f7'} />
|
||
</motion.span>
|
||
))}
|
||
</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>
|
||
{/* نمرهی کم: تلاش و راه را ستایش کن، نه «باهوشی» را (پژوهشهای ذهنیتِ رشد)؛ و راهِ بعدی را نشان بده. */}
|
||
{result.stars <= 1 && (
|
||
<p className="mt-2 text-xs leading-6 text-body">
|
||
اشتباهها همون جاهاییان که مغزت داره یاد میگیره. هر وقت خواستی از «درسها» دوباره مرورش کن.
|
||
</p>
|
||
)}
|
||
</div>
|
||
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمیشود. */}
|
||
{(result.xpEarned > 0 || result.coinsEarned > 0) && <div className="flex w-full gap-3">
|
||
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
||
<Star size={18} className="text-gold" />
|
||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.xpEarned)}</span>
|
||
<span className="text-[10px] text-muted">امتیاز</span>
|
||
</div>
|
||
<div className="flex flex-1 flex-col items-center rounded-2xl border border-line bg-white py-3">
|
||
<Coins size={18} className="text-violet" />
|
||
<span dir="ltr" className="mt-1 text-lg font-black text-ink">+{toFa(result.coinsEarned)}</span>
|
||
<span className="text-[10px] text-muted">سکه</span>
|
||
</div>
|
||
</div>}
|
||
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
|
||
{nodeMode && nextNode ? 'بریم جزیره بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||
</PrimaryButton>
|
||
{nodeMode && nextNode && (
|
||
<button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
|
||
فعلاً بسه، برگردیم
|
||
</button>
|
||
)}
|
||
</div>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* پیامِ بعد از جواب. یک جملهی ثابت بعد از چند بار دیگر خوانده نمیشود؛ چند تا به نوبت. برای جوابِ غلط
|
||
* تلاش و راه ستایش میشود و قدمِ بعدی گفته میشود، نه اینکه «اشکالی نداره» تنها بماند.
|
||
*/
|
||
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 (
|
||
<motion.span key={correct} initial={{ scale: 1.4 }} animate={{ scale: 1 }} className="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||
Σ {toFa(correct)}
|
||
</motion.span>
|
||
);
|
||
}
|
||
if (char === 'delta' && combo >= 2) {
|
||
return (
|
||
<motion.span key={combo} initial={{ scale: 0.4, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500, damping: 12 }} className="flex flex-shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
|
||
<StreakFlame state="lit" size={11} className="[&_path]:fill-white" /> ×{toFa(combo)}
|
||
</motion.span>
|
||
);
|
||
}
|
||
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 (
|
||
<motion.span
|
||
aria-hidden
|
||
className="mr-1.5 inline-block align-middle"
|
||
initial={{ scale: 0.4, rotate: 0 }}
|
||
animate={{ scale: 1, rotate: [0, -14, 14, -8, 8, 0] }}
|
||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||
>
|
||
<Icon size={18} color={color} strokeWidth={2.4} />
|
||
</motion.span>
|
||
);
|
||
}
|