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:
209
src/screens/LoginScreen.tsx
Normal file
209
src/screens/LoginScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user