forked from Tadrisino/MathApp_frontend
- http: a request whose 401 arrived after another request's refresh had finished refreshed again; it now resends with the new token. A refresh finishing after logout no longer restores tokens. - logout sends the refresh token current at send time (after a 401 -> refresh it had sent the rotated-out one and left the new session alive on the server for 30 days). - Sign-out/sign-in in another browser tab is followed (storage event). - Navigator removes its history entries when the main app unmounts (Back did nothing after logout). - Login: +98 / 0098 / 9xxxxxxxxx phone input (autofill, typing, paste) normalised; incomplete OTP is not sent (each wrong guess burns one of five). - Quiz resumes at the same question after hearts refill instead of restarting; the no-hearts screen re-checks while due (phone clock ahead of the server left it stuck at zero). - Payment return: a pending order is re-checked for about a minute instead of shown as failed; the pay button is reset when the page comes back from bfcache. - Tutor: retry keeps the quiz question id; a network error no longer forgets the conversation; daily quota detected by code ai_daily_quota. - Root error boundary and vite:preloadError reload instead of a white screen; deploy.sh keeps hashed assets of earlier builds for 30 days so open apps can still load their chunks. - Sheet: labelled dialog, focus moves in and back, Escape closes only the top sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
9.6 KiB
TypeScript
212 lines
9.6 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);
|
||
// کدِ سرور ۶ رقمی است و هر حدسِ غلط یکی از ۵ فرصت را میسوزاند؛ کدِ ناقص نباید فرستاده شود.
|
||
// فقط در حالتِ توسعه کدِ ثابتِ ۴ رقمیِ DEV_MASTER_OTP هم هست.
|
||
const otpValid = otp.length === OTP_LENGTH || (import.meta.env.DEV && 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 === OTP_LENGTH || (import.meta.env.DEV && 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(normalizePhoneInput(e.target.value))}
|
||
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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* پرکردنِ خودکارِ گوشی و چسباندن معمولاً «+98 912 …» یا «0098…» میدهد؛ قبلاً فقط ۱۱ رقمِ اول نگه داشته میشد
|
||
* («98912…») و دکمه بیتوضیح خاموش میماند. سرور هم همین شکلها را میپذیرد (accounts/phone.py).
|
||
*/
|
||
function normalizePhoneInput(value: string): string {
|
||
let digits = digitsOnly(value);
|
||
if (digits.startsWith('0098')) digits = `0${digits.slice(4)}`;
|
||
else if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
|
||
// «98…» هنوز در حالِ تایپ است (۱۰ و ۱۱ رقمِ وسطِ «+98912…» هم با ۹ شروع میشوند)؛ پیششمارهی ۰۹۸ نداریم.
|
||
else if (digits.startsWith('9') && !digits.startsWith('98') && digits.length === 10) digits = `0${digits}`;
|
||
return digits.slice(0, 11);
|
||
}
|