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

@@ -1,9 +1,10 @@
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { CheckCircle2, XCircle } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
import { AppDataProvider, useAppData } from '@/state/AppData';
import { NavigatorProvider, useNav } from '@/nav/Navigator';
import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData';
import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator';
import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync';
import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet';
@@ -50,15 +51,16 @@ function preloadScreens() {
interface Props {
profile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
payment: PaymentReturn | null;
onSignedOut: () => void;
}
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []);
return (
<AppDataProvider initialProfile={profile} grades={grades}>
<AppDataProvider initialProfile={profile} grades={grades} prefetch={prefetch}>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />}
@@ -67,44 +69,215 @@ export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
);
}
/**
* صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده
* می‌شود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمه‌کاره می‌آمد (کارتِ خالی، «نداری»،
* عددِ صفر) و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید؛ همان «چشمک»ی که روی گوشی دیده می‌شد.
*
* اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش می‌آید تا لمسِ کاربر بی‌جواب نماند؛ در این فاصله
* نوارِ باریکِ بالا نشان می‌دهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخ‌گویی آمده‌اند (زیرِ ~۱۰۰ms
* «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکه‌ی ضعیف سنجیده نشده‌اند.
*/
const HOLD_MAX_MS = 450;
const HOLD_BAR_AFTER_MS = 120;
interface Layer {
id: number;
route: Route;
direction: NavDirection;
/** ریشه‌ی پشته است (تب)، نه صفحه‌ای که روی آن باز شده؛ نوارِ پایین فقط آن‌جا هست. */
root: boolean;
shown: boolean;
}
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
const nav = useNav();
const { route } = nav;
const showNav = isTab(route.name) && nav.tab === route.name;
// صفحه‌ی اولِ بعد از ورود بی‌انیمیشنِ ورود می‌آید، ولی این را روی خودِ motion.div می‌گذاریم نه
// `<AnimatePresence initial={false}>`: آن یکی به همه‌ی فرزندهای motionِ صفحه‌ی اول هم سرایت می‌کند و
// انیمیشن‌های تکرارشونده‌شان (ماسکوتِ بالا‌پایین، حلقه‌ی جزیره، دکمه‌ی «شروع») تا اولین تعویضِ تب شروع نمی‌شدند.
const firstRender = useRef(true);
const { route, direction, canGoBack } = nav;
const nextId = useRef(1);
// صفحه‌ی اولِ بعد از ورود بی‌انتظار و بی‌حرکت می‌آید (داده‌ی خانه پیش از آن در اسپلش گرفته شده).
const [layers, setLayers] = useState<Layer[]>(() => [{ id: 0, route, direction, root: true, shown: true }]);
useEffect(() => {
firstRender.current = false;
setLayers((prev) => {
if (prev[prev.length - 1].route === route) return prev;
// صفحه‌ای که هنوز نیامده و کاربر از آن گذشته، همان‌جا کنار می‌رود.
const visible = prev.filter((l) => l.shown);
return [...visible, { id: nextId.current++, route, direction, root: !canGoBack, shown: false }];
});
}, [route, direction, canGoBack]);
const reveal = useCallback((id: number) => {
setLayers((prev) => prev.map((l) => (l.id === id ? { ...l, shown: true } : l)));
}, []);
const settle = useCallback((id: number) => {
// صفحه‌ی تازه کامل آمد؛ هرچه زیرش است برداشته شود.
setLayers((prev) => {
const i = prev.findIndex((l) => l.id === id);
return i > 0 ? prev.slice(i) : prev;
});
}, []);
const top = [...layers].reverse().find((l) => l.shown) ?? layers[0];
// زدنِ دوباره‌ی تبِ باز: بخشِ اسکرول‌شونده‌ی صفحه‌ی دیده‌شده نرم به بالا می‌رود.
const stage = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!nav.reselect) return;
const layer = stage.current?.querySelector<HTMLElement>(`[data-layer="${top.id}"]`);
layer?.querySelectorAll<HTMLElement>('.overflow-y-auto').forEach((el) => el.scrollTo({ top: 0, behavior: 'smooth' }));
// فقط با زدنِ دوباره، نه با عوض شدنِ لایه.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nav.reselect]);
const waiting = !layers[layers.length - 1].shown;
// نوارِ پایین از صفحه‌ای که دیده می‌شود پیروی می‌کند، نه از صفحه‌ای که هنوز پنهان است؛ وگرنه ارتفاعِ صفحه‌ی
// فعلی پیش از رفتن عوض می‌شد و می‌پرید. تبِ روشن ولی همان لحظه‌ی لمس عوض می‌شود (بازخوردِ فوری).
const showNav = isTab(top.route.name) && top.root;
return (
<>
<div className="relative flex-1 overflow-hidden bg-page">
<AnimatePresence mode="popLayout">
<motion.div
key={JSON.stringify(route)}
className="absolute inset-0 flex flex-col bg-page"
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
animate={{ opacity: 1, x: 0 }}
// صفحه‌ی قبلی نمی‌پرد و محو نمی‌شود، زیرِ صفحه‌ی تازه می‌ماند تا کامل بیاید. با محوشدنِ هم‌زمانِ
// هر دو، پس‌زمینه‌ی تیره‌ی body از وسط دیده می‌شد و تعویضِ تب «گلیچ» می‌زد (از روی کد؛ با
// فیلمِ دستگاهِ واقعی سنجیده نشده).
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
transition={{ duration: 0.16, ease: 'easeOut' }}
>
<Suspense fallback={<LoadingState />}>
<RouteView route={route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</AnimatePresence>
<div ref={stage} className="relative flex-1 overflow-hidden bg-page">
{layers.map((layer) => (
<ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
))}
<HoldBar active={waiting} />
</div>
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
</>
);
}
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
// در راست‌به‌چپ صفحه‌ی بعدی از چپ می‌آید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپ‌های بومی.
push: { opacity: 0, x: -28 },
replace: { opacity: 0, x: -28 },
back: { opacity: 0, x: 28 },
tab: { opacity: 0, x: 0 },
};
function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: {
layer: Layer;
onReady: (id: number) => void;
onSettled: (id: number) => void;
} & Pick<Props, 'onSignedOut'>) {
const { id, shown } = layer;
const initiallyShown = useRef(shown).current;
const gate = useGate(initiallyShown ? null : () => onReady(id));
// اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحه‌های زیرین باز هم برداشته شوند.
useEffect(() => {
if (!shown || initiallyShown) return;
const t = setTimeout(() => onSettled(id), 700);
return () => clearTimeout(t);
}, [shown, initiallyShown, id, onSettled]);
return (
<ScreenGateContext.Provider value={gate}>
<motion.div
className="absolute inset-0 flex flex-col bg-page"
initial={initiallyShown ? false : ENTER[layer.direction]}
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
onAnimationComplete={() => shown && onSettled(id)}
data-layer={id}
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
aria-hidden={!shown}
// صفحه‌ی پنهان نباید با Tab صفحه‌کلید فوکوس بگیرد. (React 18 «inert» را فقط به‌صورتِ رشته رد می‌کند.)
{...((!shown ? { inert: '' } : {}) as object)}
>
<Suspense fallback={<SuspenseHold />}>
<RouteView route={layer.route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</ScreenGateContext.Provider>
);
}
/**
* شمارنده‌ی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا
* زده می‌شود. React افکت‌های فرزند را پیش از پدر اجرا می‌کند، پس تا افکتِ این‌جا همه‌ی useAsyncهای صفحه
* hold گرفته‌اند.
*/
function useGate(onReady: (() => void) | null): ScreenGate | null {
const readyRef = useRef(onReady);
readyRef.current = onReady;
const state = useRef({ count: 0, mounted: false, done: onReady === null });
const fire = useCallback(() => {
const s = state.current;
if (s.done) return;
s.done = true;
readyRef.current?.();
}, []);
const gate = useMemo<ScreenGate | null>(
() =>
onReady === null
? null
: {
hold() {
const s = state.current;
if (s.done) return () => {};
s.count++;
let released = false;
return () => {
if (released) return;
released = true;
s.count--;
// یک microtask صبر: در StrictMode (و جابه‌جاییِ Suspense) افکت‌ها پاک و دوباره اجرا می‌شوند و
// hold تازه بلافاصله گرفته می‌شود؛ بی‌این صبر دروازه پیش از رسیدنِ داده باز می‌شد.
queueMicrotask(() => {
if (s.mounted && s.count === 0) fire();
});
};
},
},
// فقط بارِ اول؛ لایه‌ای که یک بار نشان داده شد دیگر دروازه ندارد.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
const s = state.current;
if (s.done) return;
s.mounted = true;
if (s.count === 0) {
fire();
return;
}
const t = setTimeout(fire, HOLD_MAX_MS);
return () => {
s.mounted = false;
clearTimeout(t);
};
}, [fire]);
return gate;
}
/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته می‌ماند (معمولاً از قبل در پس‌زمینه گرفته شده). */
function SuspenseHold() {
const gate = useContext(ScreenGateContext);
useEffect(() => {
const release = gate?.hold();
// آزاد کردن خودش یک microtask صبر می‌کند (useGate)، پس useAsyncهای صفحه‌ی تازه پیش از آن hold گرفته‌اند.
return () => release?.();
}, [gate]);
return <LoadingState />;
}
/** نوارِ باریکِ بالای صفحه وقتی صفحه‌ی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */
function HoldBar({ active }: { active: boolean }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS);
return () => clearTimeout(t);
}, [active]);
return (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
</div>
);
}
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
switch (route.name) {
case 'home': return <HomeScreen />;