Files
MathApp_frontend/src/screens/LoginScreen.tsx
AliReza b5d97c0cda open the code step at once and let the SMS request finish in the background
Kavenegar sends the SMS early but answers our API up to 16 s later
(2026-09-27), and the login screen waited for that answer. The server
stores the code before calling Kavenegar, so the code works immediately
(checked against a local server with an 8 s send: login 200 at 1.5 s).
Real failures still show on the code step, and resend unlocks at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:31:06 +03:30

234 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { ChevronRight, Phone } from 'lucide-react';
import { api, ApiError } 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<'login' | 'guest' | null>(null);
const [error, setError] = useState('');
const [resendIn, setResendIn] = useState(0);
/** درخواستِ پیامک هنوز جواب نداده؛ جدا از `loading` تا واردکردنِ کد قفل نشود. */
const [sending, setSending] = useState(false);
/** شماره‌ی آخرین درخواست؛ جوابِ دیرِ درخواستِ قدیمی‌تر نادیده گرفته می‌شود. */
const attempt = useRef(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 || sending || loading) return;
const mine = ++attempt.current;
setError('');
setDevOtp(null);
setOtp('');
setStep('otp');
setResendIn(RESEND_SECONDS);
setSending(true);
try {
const data = await api.auth.requestOtp(phone);
if (mine !== attempt.current) return;
setDevOtp(data.devOtp ?? null);
} catch (err) {
if (mine !== attempt.current) return;
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
if (err instanceof ApiError && err.status === 400) {
setStep('phone'); // شماره را سرور نپذیرفت؛ برگرد تا اصلاحش کند
} else if (err instanceof ApiError && err.isRateLimited && err.retryAfterMs) {
setResendIn(Math.ceil(err.retryAfterMs / 1000));
} else {
setResendIn(0); // پیامکی نرفت و سرور cooldown را برداشته؛ همین حالا دوباره بفرستد
}
} finally {
if (mine === attempt.current) setSending(false);
}
}
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>
{sending && !error && (
<p className="mt-2 text-xs text-muted">پیامک در راهه؛ همین که رسید کد رو بزن</p>
)}
{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={() => { attempt.current++; setSending(false); setStep('phone'); setError(''); setDevOtp(null); }}>
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button>
<button type="button" disabled={resendIn > 0 || sending || 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 === '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>
);
}