diff --git a/src/assets/fail_fallback.svg b/src/assets/fail_fallback.svg new file mode 100644 index 0000000..1c902b2 --- /dev/null +++ b/src/assets/fail_fallback.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/pages/AddEvent.tsx b/src/pages/AddEvent.tsx index 6201158..37158f1 100644 --- a/src/pages/AddEvent.tsx +++ b/src/pages/AddEvent.tsx @@ -62,22 +62,28 @@ function MatchSelect({ }) { const [open, setOpen] = useState(false) const selectedMatch = options.find((match) => match.uuid === value) + const hasMatches = options.length > 0 return (
- {open && ( + {open && hasMatches && ( <>
setOpen(false)} />
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4f0a956..2d0e360 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -135,20 +135,24 @@ function MatchSelectorRow({ }) { const [open, setOpen] = useState(false) const selectedMatch = options.find((match) => match.uuid === value) + const hasMatches = options.length > 0 return (

{label}

- {open && ( + {open && hasMatches && ( <>
setOpen(false)} />
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 0c2ffbc..499092c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -52,7 +52,13 @@ export default function Login() { }; return ( -
+
{ + e.preventDefault(); + if (canContinue) handleContinue(); + }} + className="flex min-h-screen flex-col justify-center px-6" + >

ورود به وینسو

@@ -64,6 +70,7 @@ export default function Login() { type="text" dir="ltr" inputMode="numeric" + enterKeyHint="go" value={phone} onChange={(e) => { setPhone(toPersianDigits(e.target.value)); @@ -76,9 +83,8 @@ export default function Login() { {error &&

{error}

}
-
+ ); } diff --git a/src/pages/LoginOtp.tsx b/src/pages/LoginOtp.tsx index 0d0a6c5..ef70874 100644 --- a/src/pages/LoginOtp.tsx +++ b/src/pages/LoginOtp.tsx @@ -12,6 +12,11 @@ import { getUserRole, setUserRole, UserRole } from '../utils/role' const OTP_LENGTH = 5 const DEFAULT_RESEND_SECONDS = 120 +// not yet in TS's DOM lib — minimal shape for the WebOTP API result +interface OTPCredential extends Credential { + code: string +} + function EditIcon({ className = '' }: { className?: string }) { return ( @@ -50,12 +55,50 @@ export default function LoginOtp() { const [isResending, setIsResending] = useState(false) const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + // fills consecutive boxes starting at startIndex — used by both the WebOTP + // listener below and a multi-digit autofill landing in a single box (e.g. iOS's + // autocomplete="one-time-code" suggestion pastes the whole code into one input) + const fillOtp = (startIndex: number, rawValue: string) => { + const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(0, OTP_LENGTH - startIndex) + if (!digits) return + setOtp((prev) => { + const next = [...prev] + digits.split('').forEach((d, i) => { + next[startIndex + i] = d + }) + return next + }) + setError('') + inputRefs.current[Math.min(startIndex + digits.length, OTP_LENGTH) - 1]?.focus() + } + useEffect(() => { if (!phone) { navigate(loginPath, { replace: true }) } }, [phone, loginPath, navigate]) + useEffect(() => { + inputRefs.current[0]?.focus() + }, []) + + // Android/Chrome: WebOTP lets the browser read the code straight off an incoming + // SMS without the user opening the messaging app — only fires if the SMS body ends + // with a line like "@ #", which the backend's SMS + // template doesn't currently send, so this is a no-op until that's added. + useEffect(() => { + if (!('OTPCredential' in window)) return + const abortController = new AbortController() + navigator.credentials + .get({ otp: { transport: ['sms'] }, signal: abortController.signal } as unknown as CredentialRequestOptions) + .then((credential) => { + const code = (credential as OTPCredential | null)?.code + if (code) fillOtp(0, code) + }) + .catch(() => {}) + return () => abortController.abort() + }, []) + useEffect(() => { const timer = setInterval(() => { setSecondsLeft((s) => { @@ -70,14 +113,19 @@ export default function LoginOtp() { }, [resendCycle]) const handleOtpChange = (index: number, rawValue: string) => { - const digit = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(-1) + const digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, '') + // a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion + if (digits.length > 1) { + fillOtp(index, digits) + return + } setOtp((prev) => { const next = [...prev] - next[index] = digit + next[index] = digits return next }) setError('') - if (digit && index < OTP_LENGTH - 1) { + if (digits && index < OTP_LENGTH - 1) { inputRefs.current[index + 1]?.focus() } } @@ -136,7 +184,13 @@ export default function LoginOtp() { } return ( -
+
{ + e.preventDefault() + if (canSubmit) handleSubmit() + }} + className="flex min-h-screen flex-col justify-center px-6" + >

کد ارسال شده را وارد کنید:

{phone} @@ -154,7 +208,11 @@ export default function LoginOtp() { }} type="text" inputMode="numeric" - maxLength={1} + autoComplete="one-time-code" + enterKeyHint="go" + // no maxLength — the browser would truncate a pasted/autofilled multi-digit + // code to 1 char before onChange ever sees it; handleOtpChange enforces the + // single-digit display for normal typing instead value={digit} onChange={(e) => handleOtpChange(index, e.target.value)} onKeyDown={(e) => handleOtpKeyDown(index, e)} @@ -178,13 +236,12 @@ export default function LoginOtp() { {error &&

{error}

} -
+
) }