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({ kind: 'boot' }); const [splashVisible, setSplashVisible] = useState(false); const splashShownAt = useRef(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 جابه‌جایی‌ها را حذف می‌کند. {flow.kind === 'boot' && } {flow.kind === 'bootError' && (
void boot()} />
)} {flow.kind === 'onboarding' && ( { store.set(KEYS.onboarded, '1'); setFlow({ kind: 'login' }); }} /> )} {flow.kind === 'login' && void afterLogin()} />} {flow.kind === 'setup' && ( setFlow({ kind: 'main', profile, grades: flow.grades })} /> )} {flow.kind === 'main' && ( { clearAsyncCache(); setFlow({ kind: 'login' }); }} /> )}
); } /** داده‌ی صفحه‌ی اول؛ هر کدام که نرسید null می‌ماند و AppData خودش دوباره می‌گیرد. */ async function prefetchMain(): Promise { 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); } }