Files
MathApp_frontend/src/screens/LoginScreen.tsx

197 lines
8.3 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';
const OTP_LENGTH = 6;
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
const RESEND_SECONDS = 120;
/**
* ورود با شماره (OTP): تنها راهِ ورود است.
*/
export function LoginScreen({ onDone, onCancel }: { onDone: () => void; onCancel?: () => void }) {
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' | 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);
}
}
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-balance text-xl font-black">ورود به تدریسینو ریاضی</h1>
<p className="mt-1 text-sm text-white/75">
{step === 'phone'
? 'شماره موبایلت رو وارد کن'
: `کد ${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>
</div>
</form>
</div>
);
}