Files
MathApp_frontend/src/app/App.tsx
AliReza c7407f32b9 Splash only appears when the server is slow: no flash of the welcome screen on a fast start
The splash used to stay a fixed 700ms plus a fade, so a fast response still showed it
briefly and jumped away. Now only the matching background shows for the first 350ms;
the splash content appears only if the response has not arrived by then, and once shown
it stays at least 700ms. Not run on a device.
2026-10-11 01:19:34 +03:30

186 lines
7.8 KiB
TypeScript

import { useCallback, useEffect, useRef, 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';
import { SplashScreen } from '@/screens/SplashScreen';
import { MainApp } from './MainApp';
import { launchPayment } from './launchParams';
type Flow =
| { kind: 'boot' }
| { kind: 'bootError'; error: unknown }
| { kind: 'onboarding' }
| { kind: 'login' }
| { kind: 'setup'; profile: UserProfile; grades: Grade[] }
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch };
/**
* اسپلش فقط وقتی نشان داده می‌شود که سرور دیرتر از SPLASH_DELAY_MS جواب بدهد؛ اگر زودتر رسید، کاربر
* اسپلش را نمی‌بیند و مستقیم به صفحه‌ی بعدی می‌رود (به‌جای چند صد میلی‌ثانیه چشمک).
* اگر یک بار نشان داده شد، دست‌کم SPLASH_MIN_MS می‌ماند تا خودش چشمک نزند.
* تا آن لحظه فقط پس‌زمینه‌ی هم‌رنگِ اسپلش دیده می‌شود، نه صفحه‌ی سفید.
*/
const SPLASH_DELAY_MS = 350;
const SPLASH_MIN_MS = 700;
/**
* جریانِ ورود: اسپلش → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/
export function App() {
const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
const [splashVisible, setSplashVisible] = useState(false);
const splashShownAt = useRef<number | null>(null);
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => {
// نقشه و آمار هم‌زمان با پروفایل گرفته می‌شوند تا خانه کامل بیاید، نه اول «دارم مسیرت رو میارم» و آمارِ صفر.
// شکستشان ورود را نمی‌خواباند: 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, prefetch });
else setFlow({ kind: 'setup', profile, grades });
}, []);
const boot = useCallback(async () => {
setFlow({ kind: 'boot' });
splashShownAt.current = null;
setSplashVisible(false);
const timer = setTimeout(() => {
splashShownAt.current = Date.now();
setSplashVisible(true);
}, SPLASH_DELAY_MS);
let next: Flow | null = null;
if (api.auth.isSignedIn()) {
try {
await enter();
} catch (error) {
// ۴۰۱ یعنی نشست باطل شده و onSignedOut خودش به ورود می‌برد؛
// خطای شبکه نباید کاربر را بی‌دلیل از حساب بیرون بیندازد.
if (!(error instanceof ApiError && error.isUnauthorized)) next = { kind: 'bootError', error };
else next = { kind: 'login' };
}
} else {
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' };
}
clearTimeout(timer);
// اسپلش دیده شده؟ فقط در آن صورت تا کمینه‌ی زمانش نگه دار.
const shownAt = splashShownAt.current;
if (shownAt !== null) {
const wait = SPLASH_MIN_MS - (Date.now() - shownAt);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
}
if (next) setFlow(next);
}, [enter]);
useEffect(() => {
void boot();
}, [boot]);
// هر جا نشست باطل شد (refresh هم شکست خورد)، برگرد به صفحه‌ی ورود.
useEffect(
() =>
http.onSignedOut(() => {
askStore.clear();
clearAsyncCache();
setFlow({ kind: 'login' });
}),
[],
);
const afterLogin = async () => {
try {
await enter();
} catch (error) {
setFlow({ kind: 'bootError', error });
}
};
return (
// reducedMotion="user": اگر «کاهشِ حرکت» در گوشی روشن باشد، motion جابه‌جایی‌ها را حذف می‌کند.
<MotionConfig reducedMotion="user">
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
// اگر اسپلش هرگز دیده نشد، محو‌شدن هم لازم نیست (وگرنه همان چشمک از راهِ دیگر برمی‌گردد).
transition={{ duration: splashVisible || flow.kind !== 'boot' ? 0.2 : 0 }}
>
{flow.kind === 'boot' && <SplashScreen showContent={splashVisible} />}
{flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} />
</div>
)}
{flow.kind === 'onboarding' && (
<OnboardingScreen
onDone={() => {
store.set(KEYS.onboarded, '1');
setFlow({ kind: 'login' });
}}
/>
)}
{flow.kind === 'login' && <LoginScreen onDone={() => void afterLogin()} />}
{flow.kind === 'setup' && (
<SetupScreen
profile={flow.profile}
grades={flow.grades}
onDone={(profile) => setFlow({ kind: 'main', profile, grades: flow.grades })}
/>
)}
{flow.kind === 'main' && (
<MainApp
profile={flow.profile}
grades={flow.grades}
prefetch={flow.prefetch}
payment={launchPayment}
onSignedOut={() => {
clearAsyncCache();
setFlow({ kind: 'login' });
}}
/>
)}
</motion.div>
</AnimatePresence>
</ToastProvider>
</AppShell>
</MotionConfig>
);
}
/** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید 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);
if (!code) return;
try {
await api.affiliate.track(code);
store.remove(KEYS.referralCode);
} catch (err) {
// کدِ نامعتبر یا معرفیِ خود: دوباره امتحان کردن فایده ندارد.
if (err instanceof ApiError && err.status >= 400 && err.status < 500) store.remove(KEYS.referralCode);
}
}