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

209
src/screens/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronRight, Phone } from 'lucide-react';
import { api } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, toFa } from '@/lib/format';
import { getDeviceId } from '@/lib/storage';
const OTP_LENGTH = 6;
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
const RESEND_SECONDS = 120;
/**
* ورود با شماره (OTP) یا به‌عنوانِ مهمان.
*
* `upgrade` برای مهمانی است که از صفحه‌ی «من» می‌خواهد شماره ثبت کند؛ آنجا
* گزینه‌ی مهمان نیست و سرور همان کاربر را نگه می‌دارد.
*/
export function LoginScreen({
onDone,
onCancel,
upgrade = false,
}: {
onDone: () => void;
onCancel?: () => void;
upgrade?: boolean;
}) {
const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
const [devOtp, setDevOtp] = useState<string | null>(null);
const [loading, setLoading] = useState<'otp' | 'login' | 'guest' | null>(null);
const [error, setError] = useState('');
const [resendIn, setResendIn] = useState(0);
const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone);
const otpValid = otp.length >= 4;
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
useEffect(() => {
if (step === 'otp') otpInput.current?.focus();
}, [step]);
async function requestOtp() {
if (!phoneValid || loading) return;
setError('');
setLoading('otp');
try {
const data = await api.auth.requestOtp(phone);
setDevOtp(data.devOtp ?? null);
setOtp('');
setStep('otp');
setResendIn(RESEND_SECONDS);
} catch (err) {
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
async function verify(code = otp) {
if (code.length < 4 || loading) return;
setError('');
setLoading('login');
try {
await api.auth.login(phone, code);
onDone();
} catch (err) {
setError(friendlyError(err, 'کد درست نبود، دوباره امتحان کن'));
setOtp('');
} finally {
setLoading(null);
}
}
async function continueAsGuest() {
setError('');
setLoading('guest');
try {
await api.auth.guest(getDeviceId());
onDone();
} catch (err) {
// قبلاً اینجا یک «مهمانِ محلی» بدونِ توکن ساخته می‌شد و بعد همه‌ی
// درخواست‌ها بی‌صدا شکست می‌خوردند. حالا همین‌جا می‌گوییم چه شده.
setError(friendlyError(err, 'ورودِ مهمان انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
function onOtpChange(value: string) {
const code = digitsOnly(value, OTP_LENGTH);
setOtp(code);
if (code.length === OTP_LENGTH) void verify(code);
}
return (
<div className="flex h-full flex-col bg-page">
<header className="rounded-b-[32px] bg-coral px-5 pb-6 pt-safe text-white">
<div className="pt-8">
{onCancel && (
<button type="button" aria-label="بازگشت" onClick={onCancel} className="mb-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} />
</button>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شماره‌ی موبایل' : 'ورود به تدریسینو'}</h1>
<p className="mt-1 text-sm text-white/75">
{step === 'phone'
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش می‌مونه' : 'شماره‌ی موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`}
</p>
</div>
<div className="rounded-2xl bg-white/15 p-2">
<SeyarMascot char="sigma" mood="happy" size={68} />
</div>
</div>
</div>
</header>
<form
className="flex flex-1 flex-col gap-5 overflow-y-auto px-5 py-6"
onSubmit={(e) => {
e.preventDefault();
void (step === 'phone' ? requestOtp() : verify());
}}
>
{step === 'phone' ? (
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
autoComplete="tel"
autoFocus
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-left text-base font-medium tracking-wider text-ink outline-none transition-colors placeholder:text-faint ${
error ? 'border-red-400' : phone ? 'border-coral' : 'border-line'
}`}
/>
</label>
) : (
<div>
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
<input
ref={otpInput}
value={otp}
onChange={(e) => onOtpChange(e.target.value)}
placeholder="––––––"
dir="ltr"
inputMode="numeric"
autoComplete="one-time-code"
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-center text-2xl font-black tracking-[0.5em] text-ink outline-none transition-colors placeholder:text-faint ${
error ? 'border-red-400' : otp ? 'border-coral' : 'border-line'
}`}
/>
</label>
{devOtp && (
<p className="mt-2 text-xs text-muted">
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
</p>
)}
<div className="mt-3 flex items-center justify-between text-xs font-bold">
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { setStep('phone'); setError(''); setDevOtp(null); }}>
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button>
<button type="button" disabled={resendIn > 0 || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
</button>
</div>
</div>
)}
{error && <InlineError>{error}</InlineError>}
<div className="mt-auto flex flex-col gap-3">
<PrimaryButton
type="submit"
loading={loading === 'otp' || loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid}
>
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
</PrimaryButton>
{!upgrade && (
<button
type="button"
onClick={() => void continueAsGuest()}
disabled={loading !== null}
className="py-2 text-center text-sm font-bold text-muted disabled:opacity-40"
>
{loading === 'guest' ? 'در حالِ ورود…' : 'فعلاً مهمان باشم'}
</button>
)}
</div>
</form>
</div>
);
}