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:
2026-09-26 23:35:48 +03:30
parent b411b636f4
commit 348bfecea6
120 changed files with 5840 additions and 12783 deletions

View File

@@ -1,3 +1,68 @@
@import './fonts.css';
@import './tailwind.css';
@import './theme.css';
/* فونت از خودِ اپ سرو می‌شود؛ Google Fonts از داخلِ ایران کند یا مسدود است. */
@import 'vazirmatn/Vazirmatn-Variable-font-face.css';
@import 'tailwindcss';
/*
* پالتِ اپ — همان رنگ‌های طراحیِ اولیه، یک‌جا.
* هر رنگ یک کلاسِ Tailwind می‌سازد: bg-coral، text-ink، border-line، …
*/
@theme {
--font-sans: 'Vazirmatn', system-ui, sans-serif;
--color-coral: #ED6956; /* رنگِ اصلیِ برند */
--color-steel: #91ADC6; /* نقشه */
--color-teal: #97C0C0; /* درس‌ها */
--color-violet: #9B7BFF; /* دستیار (پی) */
--color-orange: #FF7A3D; /* جدولِ امتیاز (دلتا) */
--color-indigo: #4C5FD5; /* سیگما */
--color-rose: #F3A9B0; /* اسپلش */
--color-gold: #E8A400;
--color-mint: #5FBF8F;
--color-ink: #1a1a2e; /* متنِ اصلی */
--color-body: #3a3a5c; /* متنِ بدنه */
--color-muted: #9e9ec0; /* متنِ کم‌رنگ */
--color-faint: #b0b0c8;
--color-line: #ede9ff; /* حاشیه‌ی کارت */
--color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */
--color-page: #f0f4ff; /* پس‌زمینه‌ی صفحه */
}
@layer base {
html, body, #root {
height: 100%;
}
body {
margin: 0;
background: #14142a;
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
button {
cursor: pointer;
font: inherit;
}
button:disabled {
cursor: not-allowed;
}
input, textarea {
font: inherit;
}
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* فاصله‌ی امن بالای صفحه در گوشی‌های ناچ‌دار (وقتی اپ به صفحه‌ی اصلی اضافه شده). */
@utility pt-safe {
padding-top: max(env(safe-area-inset-top), 0px);
}
@utility pb-safe {
padding-bottom: max(env(safe-area-inset-bottom), 0px);
}