forked from Tadrisino/MathApp_frontend
rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
149
src/app/MainApp.tsx
Normal file
149
src/app/MainApp.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } 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 { 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 { 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';
|
||||
|
||||
interface Props {
|
||||
profile: UserProfile;
|
||||
grades: Grade[];
|
||||
payment: PaymentReturn | null;
|
||||
onSignedOut: () => void;
|
||||
onRegistered: (profile: UserProfile) => Promise<void>;
|
||||
}
|
||||
|
||||
/** اپِ اصلی بعد از ورود: تبها، صفحهها و دادهی مشترک. */
|
||||
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) {
|
||||
return (
|
||||
<AppDataProvider initialProfile={profile} grades={grades}>
|
||||
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
|
||||
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
||||
{payment && <PaymentResult payment={payment} />}
|
||||
</NavigatorProvider>
|
||||
</AppDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
||||
const nav = useNav();
|
||||
const { route } = nav;
|
||||
const showNav = isTab(route.name) && nav.tab === route.name;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={JSON.stringify(route)}
|
||||
className="absolute inset-0 flex flex-col"
|
||||
initial={{ opacity: 0, x: -18 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 18 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) {
|
||||
switch (route.name) {
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'map': return <MapScreen />;
|
||||
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 'family': return <FamilyScreen />;
|
||||
case 'invite': return <InviteScreen />;
|
||||
case 'register': return <RegisterView onRegistered={onRegistered} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** مهمانی که شماره ثبت میکند؛ همان کاربر میماند و فقط پروفایل تازه میشود. */
|
||||
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
|
||||
const nav = useNav();
|
||||
const { setProfile } = useAppData();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<LoginScreen
|
||||
upgrade
|
||||
onCancel={nav.back}
|
||||
onDone={async () => {
|
||||
try {
|
||||
const profile = await api.profile.get();
|
||||
setProfile(profile);
|
||||
await onRegistered(profile);
|
||||
} catch {
|
||||
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه میشود */
|
||||
}
|
||||
toast('شمارهت ثبت شد! پیشرفتت امنه.', 'success');
|
||||
nav.back();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** نتیجهی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده میشود. */
|
||||
function PaymentResult({ payment }: { payment: PaymentReturn }) {
|
||||
const { refreshSummary } = useAppData();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [paid, setPaid] = useState(payment.status === 'paid');
|
||||
|
||||
useEffect(() => {
|
||||
api.subscription
|
||||
.order(payment.orderId)
|
||||
.then((order) => setPaid(order.status === 'paid'))
|
||||
.catch(() => {});
|
||||
void refreshSummary();
|
||||
}, [payment.orderId, refreshSummary]);
|
||||
|
||||
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 ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
|
||||
<p className="text-sm leading-7 text-muted">
|
||||
{paid
|
||||
? 'اشتراکت فعال شد؛ از الان قلبهات نامحدوده.'
|
||||
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمیگرده یا اشتراکت فعال میشه.'}
|
||||
</p>
|
||||
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user