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:
@@ -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: 'صورتِ سوال گنگه' },
|
||||
|
||||
Reference in New Issue
Block a user