- 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>
44 lines
1.8 KiB
TypeScript
44 lines
1.8 KiB
TypeScript
import { memo, useEffect, useMemo, useRef } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar';
|
||
|
||
interface Props {
|
||
char: SeyarChar;
|
||
mood?: SeyarMood;
|
||
size?: number;
|
||
/** بالا و پایین رفتنِ آرام، برای ماسکوتی که منتظر است. */
|
||
bob?: boolean;
|
||
className?: string;
|
||
}
|
||
|
||
function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
|
||
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
|
||
// ظاهر شدن با فنر فقط وقتی حالت یا شخصیت عوض میشود؛ روی هر صفحهی تازه، ماسکوت دیر از صفر بزرگ
|
||
// میشد و بعد از رسیدنِ صفحه یک «پرش»ِ جدا میساخت.
|
||
const mounted = useRef(false);
|
||
useEffect(() => {
|
||
mounted.current = true;
|
||
}, []);
|
||
return (
|
||
<motion.div
|
||
key={char + mood}
|
||
role="img"
|
||
aria-label={SEYAR_INFO[char].name}
|
||
className={className}
|
||
// نه `initial={false}`: آن انیمیشنِ تکرارشوندهی bob را هم از کار میانداخت (همان باگِ ۸۸۷۹b42).
|
||
initial={mounted.current ? { scale: 0.8, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||
animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
|
||
transition={
|
||
bob
|
||
? { y: { duration: 1.8, repeat: Infinity, ease: 'easeInOut' }, scale: { type: 'spring', stiffness: 280, damping: 18 } }
|
||
: { type: 'spring', stiffness: 280, damping: 18 }
|
||
}
|
||
style={{ width: size, height: size, lineHeight: 0, flexShrink: 0 }}
|
||
// SVG از seyar.ts ساخته میشود و هیچ ورودیِ کاربر در آن نیست.
|
||
dangerouslySetInnerHTML={{ __html: svg }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export const SeyarMascot = memo(SeyarMascotBase);
|