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, 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'; import { LoadingState, PrimaryButton } from '@/components/ui'; import { HomeScreen } from '@/screens/HomeScreen'; import type { PaymentReturn } from './launchParams'; /** * خانه همراهِ خودِ اپ بار می‌شود؛ بقیه‌ی صفحه‌ها هر کدام فایلِ جدا دارند تا بارِ اول * سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکل‌ها * بزرگ‌ترین تکه‌اش). بعد از بالا آمدنِ خانه همه در پس‌زمینه گرفته می‌شوند * (preloadScreens)، پس رفتن به صفحه‌ی بعدی منتظرِ شبکه نمی‌ماند. */ const loaders: (() => Promise)[] = []; function screen>, K extends keyof M>(load: () => Promise, name: K) { loaders.push(load); return lazy(async () => ({ default: (await load())[name] })); } const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen'); const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen'); const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen'); const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen'); const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen'); const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen'); const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen'); const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen'); const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen'); const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen'); const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen'); const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen'); const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen'); const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen'); const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen'); let preloaded = false; function preloadScreens() { if (preloaded) return; preloaded = true; const run = () => loaders.forEach((load) => void load().catch(() => {})); if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 }); else setTimeout(run, 1200); } interface Props { profile: UserProfile; grades: Grade[]; prefetch?: Prefetch; payment: PaymentReturn | null; onSignedOut: () => void; } /** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */ export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) { useEffect(preloadScreens, []); return ( {payment && } ); } /** * صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده * می‌شود (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) { const nav = useNav(); const { route, direction, canGoBack } = nav; const nextId = useRef(1); // صفحه‌ی اولِ بعد از ورود بی‌انتظار و بی‌حرکت می‌آید (داده‌ی خانه پیش از آن در اسپلش گرفته شده). const [layers, setLayers] = useState(() => [{ id: 0, route, direction, root: true, shown: true }]); useEffect(() => { 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(null); useEffect(() => { if (!nav.reselect) return; const layer = stage.current?.querySelector(`[data-layer="${top.id}"]`); layer?.querySelectorAll('.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 ( <>
{layers.map((layer) => ( ))}
{showNav && } ); } const ENTER: Record = { // در راست‌به‌چپ صفحه‌ی بعدی از چپ می‌آید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپ‌های بومی. 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) { 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 ( shown && onSettled(id)} data-layer={id} style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }} aria-hidden={!shown} // صفحه‌ی پنهان نباید با Tab صفحه‌کلید فوکوس بگیرد. (React 18 «inert» را فقط به‌صورتِ رشته رد می‌کند.) {...((!shown ? { inert: '' } : {}) as object)} > }> ); } /** * شمارنده‌ی 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( () => 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 ; } /** نوارِ باریکِ بالای صفحه وقتی صفحه‌ی بعدی بیش از 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 (
{visible &&
}
); } function RouteView({ route, onSignedOut }: { route: Route } & Pick) { switch (route.name) { case 'home': return ; case 'lessons': return ; case 'ask': return ; case 'subscription': return ; case 'profile': return ; case 'unit': return ; case 'lesson': return ; case 'quiz': return ; case 'notifications': return ; case 'leaderboard': return ; case 'characters': return ; case 'streak': return ; case 'support': return ; case 'report': return ; case 'invoices': return ; case 'invite': return ; } } /** * نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر * لینکِ ساختگی می‌تواند «paid» بگذارد. */ function PaymentResult({ payment }: { payment: PaymentReturn }) { const { refreshSummary } = useAppData(); const [open, setOpen] = useState(true); const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking'); useEffect(() => { api.subscription .order(payment.orderId) .then((order) => setState(order.status === 'paid' ? 'paid' : 'failed')) .catch((err: unknown) => { console.warn('payment result lookup failed', err); setState('unknown'); }); void refreshSummary(); }, [payment.orderId, refreshSummary]); if (state === 'checking') { return ( setOpen(false)}> ); } const paid = state === 'paid'; return ( setOpen(false)}>
{paid ? : }

{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}

{paid ? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.' : state === 'unknown' ? 'چند دقیقه دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.' : 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}

setOpen(false)}>باشه
); }