forked from Tadrisino/MathApp_frontend
- Store opens with two choices (subscription / question packs); 3- and 6-month cards show per-month price and saving. - Units that need a subscription show a crown badge and open the store; a 402 from the quiz goes there too. «ادامه بده» skips locked units. - Profile: subscription card with an animated days-left bar (planDurationDays). - BrandMark (SVG) on splash, home header and footer; copyright footer; floating math glyphs on splash. CSS transform/opacity only; honours reduced motion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
9.1 KiB
TypeScript
176 lines
9.1 KiB
TypeScript
import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
|
||
import { AnimatePresence, motion } from 'motion/react';
|
||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||
import { api, type Grade, type UserProfile } from '@/api';
|
||
import { AppDataProvider, useAppData } from '@/state/AppData';
|
||
import { NavigatorProvider, useNav } from '@/nav/Navigator';
|
||
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<unknown>)[] = [];
|
||
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, 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[];
|
||
payment: PaymentReturn | null;
|
||
onSignedOut: () => void;
|
||
}
|
||
|
||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||
export function MainApp({ profile, grades, payment, onSignedOut }: Props) {
|
||
useEffect(preloadScreens, []);
|
||
return (
|
||
<AppDataProvider initialProfile={profile} grades={grades}>
|
||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||
<Screens onSignedOut={onSignedOut} />
|
||
{payment && <PaymentResult payment={payment} />}
|
||
</NavigatorProvider>
|
||
</AppDataProvider>
|
||
);
|
||
}
|
||
|
||
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
|
||
const nav = useNav();
|
||
const { route } = nav;
|
||
const showNav = isTab(route.name) && nav.tab === route.name;
|
||
// صفحهی اولِ بعد از ورود بیانیمیشنِ ورود میآید، ولی این را روی خودِ motion.div میگذاریم نه
|
||
// `<AnimatePresence initial={false}>`: آن یکی به همهی فرزندهای motionِ صفحهی اول هم سرایت میکند و
|
||
// انیمیشنهای تکرارشوندهشان (ماسکوتِ بالاپایین، حلقهی جزیره، دکمهی «شروع») تا اولین تعویضِ تب شروع نمیشدند.
|
||
const firstRender = useRef(true);
|
||
useEffect(() => {
|
||
firstRender.current = false;
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<div className="relative flex-1 overflow-hidden bg-page">
|
||
<AnimatePresence mode="popLayout">
|
||
<motion.div
|
||
key={JSON.stringify(route)}
|
||
className="absolute inset-0 flex flex-col bg-page"
|
||
initial={firstRender.current ? false : { opacity: 0, x: -14 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
// صفحهی قبلی نمیپرد و محو نمیشود، زیرِ صفحهی تازه میماند تا کامل بیاید. با محوشدنِ همزمانِ
|
||
// هر دو، پسزمینهی تیرهی body از وسط دیده میشد و تعویضِ تب «گلیچ» میزد (از روی کد؛ با
|
||
// فیلمِ دستگاهِ واقعی سنجیده نشده).
|
||
exit={{ opacity: 1, x: 0, transition: { duration: 0.16 } }}
|
||
transition={{ duration: 0.16, ease: 'easeOut' }}
|
||
>
|
||
<Suspense fallback={<LoadingState />}>
|
||
<RouteView route={route} onSignedOut={onSignedOut} />
|
||
</Suspense>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
|
||
switch (route.name) {
|
||
case 'home': return <HomeScreen />;
|
||
case 'lessons': return <LessonsScreen />;
|
||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
|
||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
||
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||
case 'quiz': return <QuizScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||
case 'notifications': return <NotificationsScreen />;
|
||
case 'leaderboard': return <LeaderboardScreen />;
|
||
case 'characters': return <CharactersScreen />;
|
||
case 'streak': return <StreakScreen />;
|
||
case 'support': return <SupportScreen />;
|
||
case 'report': return <ReportScreen />;
|
||
case 'invoices': return <InvoicesScreen />;
|
||
case 'invite': return <InviteScreen />;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* نتیجهی برگشت از درگاه. فقط پاسخِ سرور تعیین میکند پرداخت موفق بوده یا نه؛ `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 (
|
||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||
<LoadingState text="دارم پرداخت رو بررسی میکنم…" />
|
||
</Sheet>
|
||
);
|
||
}
|
||
const paid = state === 'paid';
|
||
return (
|
||
<Sheet open={open} onClose={() => setOpen(false)}>
|
||
<div className="flex flex-col items-center gap-3 pb-2 text-center">
|
||
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
|
||
<h2 className="text-lg font-black text-ink">
|
||
{paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
|
||
</h2>
|
||
<p className="text-sm leading-7 text-muted">
|
||
{paid
|
||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||
: state === 'unknown'
|
||
? 'چند دقیقهی دیگه از «پرداختها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال میشه یا پول خودکار برمیگرده.'
|
||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||
</p>
|
||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|