Files
MathApp_frontend/src/app/MainApp.tsx
AliReza a8a5351862 Subscription store, paywall on locked units, profile subscription bar, Tadrisino branding
- 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>
2026-10-10 01:19:09 +03:30

176 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}