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.
186 lines
7.8 KiB
TypeScript
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);
|
|
}
|
|
}
|