lighter first load, math reads left-to-right in quizzes, tickets from the server, readable separators

- Every screen but home is its own chunk and preloads when the browser is
  idle; React and motion get their own long-cached chunks. The app entry
  goes from 505 kB (160 kB gzip) to 85 kB (27 kB gzip).
- Quiz prompts, options and lesson examples go through MathText, so
  '10 + 3 - 7 - 2' reads from the left; an unpaired bracket at the edge of
  a math run stays with the Persian sentence.
- Support lists tickets from GET /support/tickets instead of this
  browser's localStorage; a ticket's messages load when it is opened.
- '·' next to a Persian zero read as another digit ('فصلِ ۱ · ۰' looked
  like ۱۰۰); a thin bar separates them now. Notification bodies show
  Persian digits.
- A root or vertex the model already marked with coordinates is no longer
  labelled a second time on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:18:49 +03:30
parent 63f4ea8da0
commit 0957ef476b
15 changed files with 146 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, 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';
@@ -8,25 +8,46 @@ import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { PrimaryButton } from '@/components/ui';
import { AskScreen } from '@/screens/AskScreen';
import { CharactersScreen } from '@/screens/CharactersScreen';
import { FamilyScreen } from '@/screens/FamilyScreen';
import { LoadingState, PrimaryButton } from '@/components/ui';
import { HomeScreen } from '@/screens/HomeScreen';
import { InviteScreen } from '@/screens/InviteScreen';
import { LeaderboardScreen } from '@/screens/LeaderboardScreen';
import { LessonScreen } from '@/screens/LessonScreen';
import { LoginScreen } from '@/screens/LoginScreen';
import { MapScreen } from '@/screens/MapScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { ProfileScreen } from '@/screens/ProfileScreen';
import { QuizScreen } from '@/screens/QuizScreen';
import { StreakScreen } from '@/screens/StreakScreen';
import { SubscriptionScreen } from '@/screens/SubscriptionScreen';
import { SupportScreen } from '@/screens/SupportScreen';
import { UnitScreen } from '@/screens/UnitScreen';
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 FamilyScreen = screen(() => import('@/screens/FamilyScreen'), 'FamilyScreen');
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
const MapScreen = screen(() => import('@/screens/MapScreen'), 'MapScreen');
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
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[];
@@ -39,6 +60,7 @@ interface Props {
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, start, onSignedOut, onRegistered }: Props) {
useEffect(preloadScreens, []);
return (
<AppDataProvider initialProfile={profile} grades={grades}>
<NavigatorProvider initial={start ?? { name: payment ? 'subscription' : 'home' }}>
@@ -66,7 +88,9 @@ function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onR
exit={{ opacity: 0, x: 18 }}
transition={{ duration: 0.18 }}
>
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
<Suspense fallback={<LoadingState />}>
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
</Suspense>
</motion.div>
</AnimatePresence>
</div>