Files
MathApp_frontend/src/screens/OnboardingScreen.tsx
AliReza 348bfecea6 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>
2026-09-26 23:35:48 +03:30

92 lines
4.0 KiB
TypeScript

import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Sparkles } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
{
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه‌ی سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!',
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو می‌بریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
},
{
char: 'delta', mood: 'excited', bg: '#FF7A3D',
sub: 'هر روز یه کم',
title: 'امتیاز بگیر، زنجیره بساز',
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه می‌گیری، روزهای پیاپی‌ت شمرده می‌شه و تو جدولِ امتیازها بالا می‌ری.',
},
{
char: 'pi', mood: 'happy', bg: '#9B7BFF',
sub: 'هر جا گیر کردی',
title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.',
},
];
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const slide = SLIDES[step];
const last = step === SLIDES.length - 1;
return (
<div className="relative flex h-full flex-col text-white transition-colors duration-500" style={{ backgroundColor: slide.bg }}>
{step > 0 && (
<button
type="button"
aria-label="قبلی"
onClick={() => setStep((s) => s - 1)}
className="absolute right-5 top-10 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white/15"
>
<ChevronRight size={18} strokeWidth={2.5} />
</button>
)}
<div className="flex flex-1 flex-col items-center justify-center px-8">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="rounded-3xl border border-white/10 bg-white/10 p-6">
<SeyarMascot char={slide.char} mood={slide.mood} size={150} />
</div>
<div>
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="flex justify-center gap-2 pb-4" aria-hidden>
{SLIDES.map((_, i) => (
<span key={i} className={`h-2 rounded-full transition-all duration-300 ${i === step ? 'w-6 bg-white' : 'w-2 bg-white/30'}`} />
))}
</div>
<div className="flex gap-3 px-6 pb-10">
{!last && (
<button type="button" onClick={onDone} className="flex-1 rounded-2xl border border-white/25 py-3.5 text-sm font-semibold text-white/85">
رد کردن
</button>
)}
<button
type="button"
onClick={last ? onDone : () => setStep((s) => s + 1)}
style={{ color: slide.bg }}
className="flex flex-[2] items-center justify-center gap-2 rounded-2xl bg-white py-3.5 font-black shadow-lg shadow-black/20 active:scale-95"
>
{last ? <>بزن بریم! <Sparkles size={18} /></> : <>بعدی <ChevronLeft size={18} /></>}
</button>
</div>
</div>
);
}