- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «هی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream. - Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only). - Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default). - Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
349 lines
17 KiB
TypeScript
349 lines
17 KiB
TypeScript
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<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[];
|
||
prefetch?: Prefetch;
|
||
payment: PaymentReturn | null;
|
||
onSignedOut: () => void;
|
||
}
|
||
|
||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||
export function MainApp({ profile, grades, prefetch, payment, onSignedOut }: Props) {
|
||
useEffect(preloadScreens, []);
|
||
return (
|
||
<AppDataProvider initialProfile={profile} grades={grades} prefetch={prefetch}>
|
||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||
<Screens onSignedOut={onSignedOut} />
|
||
{payment && <PaymentResult payment={payment} />}
|
||
</NavigatorProvider>
|
||
</AppDataProvider>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* صحنهی صفحهها. صفحهی تازه اول پنهان زیرِ صفحهی فعلی ساخته میشود و فقط وقتی دادهی اولش رسید نشان داده
|
||
* میشود (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<Props, 'onSignedOut'>) {
|
||
const nav = useNav();
|
||
const { route, direction, canGoBack } = nav;
|
||
const nextId = useRef(1);
|
||
// صفحهی اولِ بعد از ورود بیانتظار و بیحرکت میآید (دادهی خانه پیش از آن در اسپلش گرفته شده).
|
||
const [layers, setLayers] = useState<Layer[]>(() => [{ 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<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!nav.reselect) return;
|
||
const layer = stage.current?.querySelector<HTMLElement>(`[data-layer="${top.id}"]`);
|
||
layer?.querySelectorAll<HTMLElement>('.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 (
|
||
<>
|
||
<div ref={stage} className="relative flex-1 overflow-hidden bg-page">
|
||
{layers.map((layer) => (
|
||
<ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
|
||
))}
|
||
<HoldBar active={waiting} />
|
||
</div>
|
||
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
|
||
// در راستبهچپ صفحهی بعدی از چپ میآید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپهای بومی.
|
||
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<Props, 'onSignedOut'>) {
|
||
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 (
|
||
<ScreenGateContext.Provider value={gate}>
|
||
<motion.div
|
||
className="absolute inset-0 flex flex-col bg-page"
|
||
initial={initiallyShown ? false : ENTER[layer.direction]}
|
||
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
|
||
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
|
||
onAnimationComplete={() => shown && onSettled(id)}
|
||
data-layer={id}
|
||
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
|
||
aria-hidden={!shown}
|
||
// صفحهی پنهان نباید با Tab صفحهکلید فوکوس بگیرد. (React 18 «inert» را فقط بهصورتِ رشته رد میکند.)
|
||
{...((!shown ? { inert: '' } : {}) as object)}
|
||
>
|
||
<Suspense fallback={<SuspenseHold />}>
|
||
<RouteView route={layer.route} onSignedOut={onSignedOut} />
|
||
</Suspense>
|
||
</motion.div>
|
||
</ScreenGateContext.Provider>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* شمارندهی 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<ScreenGate | null>(
|
||
() =>
|
||
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 <LoadingState />;
|
||
}
|
||
|
||
/** نوارِ باریکِ بالای صفحه وقتی صفحهی بعدی بیش از 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 (
|
||
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
|
||
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 />;
|
||
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>
|
||
);
|
||
}
|