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:
@@ -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 />;
|
||||
|
||||
Reference in New Issue
Block a user