Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash

- Navigator stage: the next screen mounts hidden under the current one and is revealed only when its
  first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows
  after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade.
- Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly.
- Home map and stats are fetched during the splash, so the first screen arrives complete.
- Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit.
- Shop status card shows placeholders instead of a wrong «نداری» while loading.
- Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down.
- Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست»,
  growth-mindset copy after a weak round, zero-reward tiles hidden.
- Streak: a concrete next step when the streak is 0 or today is still empty.

Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency,
rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 17:55:47 +03:30
parent 19ca3d7de1
commit eafee76a93
24 changed files with 463 additions and 89 deletions

View File

@@ -16,6 +16,7 @@ import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
import { sounds } from '@/lib/sound';
import { useGateHold } from '@/lib/useAsync';
type Phase =
| { kind: 'loading' }
@@ -62,6 +63,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
const [confirmExit, setConfirmExit] = useState(false);
const [reporting, setReporting] = useState(false);
const [reload, setReload] = useState(0);
// پشتِ هم درست؛ برای «سه تا پشتِ هم!» (حسِ پیشرفت وسطِ آزمون، نه فقط آخرش).
const [combo, setCombo] = useState(0);
useGateHold(phase.kind === 'loading');
// یک کلید برای هر سوال و یکی برای پایانِ درس؛ تکرارِ درخواست دوبار ثبت نمی‌شود.
const answerKeys = useRef(new Map<string, string>());
@@ -90,6 +94,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
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 });
@@ -127,8 +132,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
if (result.correct) {
setCorrectCount((c) => c + 1);
setCombo((c) => c + 1);
sounds.correct();
} else {
setCombo(0);
sounds.wrong();
}
} catch (err) {
@@ -252,10 +259,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
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) => {
@@ -275,8 +282,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<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>
<span className="w-10 flex-shrink-0 text-left text-xs font-bold text-muted">
{toFa(index + 1)}/{toFa(questions.length)}
<span className="flex-shrink-0 text-left text-xs font-bold text-muted">
{/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */}
{toFa(index + 1)} از {toFa(questions.length)}
</span>
</div>
<HeartsRow hearts={hearts} />
@@ -451,8 +459,15 @@ function Result({
<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>
<div className="flex w-full gap-3">
{/* «+۰ امتیاز، +۰ سکه» بعد از یک دورِ بد فقط نمک روی زخم بود؛ پاداشِ صفر نشان داده نمی‌شود. */}
{(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>
@@ -463,7 +478,7 @@ function Result({
<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>
</div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton>
@@ -477,6 +492,13 @@ function Result({
);
}
/**
* پیامِ بعد از جواب. یک جمله‌ی ثابت بعد از چند بار دیگر خوانده نمی‌شود؛ چند تا به نوبت. برای جوابِ غلط
* تلاش و راه ستایش می‌شود و قدمِ بعدی گفته می‌شود، نه این‌که «اشکالی نداره» تنها بماند.
*/
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.'];
const REASONS: { id: ReportReason; label: string }[] = [
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
{ id: 'unclear', label: 'صورتِ سوال گنگه' },