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,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import type { Prefetch } from '@/state/AppData';
import { AppShell } from '@/components/AppShell';
import { ToastProvider } from '@/components/Toast';
import { ErrorState } from '@/components/ui';
import { KEYS, store } from '@/lib/storage';
import { askStore } from '@/state/askStore';
import { clearAsyncCache } from '@/lib/useAsync';
import { LoginScreen } from '@/screens/LoginScreen';
import { OnboardingScreen } from '@/screens/OnboardingScreen';
import { SetupScreen } from '@/screens/SetupScreen';
@@ -19,7 +21,7 @@ type Flow =
| { kind: 'onboarding' }
| { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[] };
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch };
/** کمترین زمانِ نمایشِ اسپلش، تا صفحه چشمک نزند. */
const SPLASH_MIN_MS = 700;
@@ -33,9 +35,11 @@ export function App() {
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => {
const [profile, grades] = await Promise.all([api.profile.get(), api.grades()]);
// نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
// شکستشان ورود را نمی‌خواباند: AppData همان‌ها را دوباره می‌گیرد و خطای نقشه را روی خانه نشان می‌دهد.
const [profile, grades, prefetch] = await Promise.all([api.profile.get(), api.grades(), prefetchMain()]);
await trackReferral();
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades });
if (profile.name && profile.gradeId) setFlow({ kind: 'main', profile, grades, prefetch });
else setFlow({ kind: 'setup', profile, grades });
}, []);
@@ -69,6 +73,7 @@ export function App() {
() =>
http.onSignedOut(() => {
askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' });
}),
[],
@@ -122,8 +127,12 @@ export function App() {
<MainApp
profile={flow.profile}
grades={flow.grades}
prefetch={flow.prefetch}
payment={launchPayment}
onSignedOut={() => setFlow({ kind: 'login' })}
onSignedOut={() => {
clearAsyncCache();
setFlow({ kind: 'login' });
}}
/>
)}
</motion.div>
@@ -134,6 +143,15 @@ export function App() {
);
}
/** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */
async function prefetchMain(): Promise<Prefetch> {
const [home, summary] = await Promise.allSettled([api.home(), api.summary()]);
return {
home: home.status === 'fulfilled' ? home.value : null,
summary: summary.status === 'fulfilled' ? summary.value : null,
};
}
/** اگر کاربر با لینکِ دعوت آمده بود و حالا وارد شده، معرفی را ثبت کن. */
async function trackReferral() {
const code = store.get(KEYS.referralCode);