197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
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>
|
||
);
|
||
}
|