feature/WNPT-247 #2

Merged
afra.khalaj merged 7 commits from feature/WNPT-247 into master 2026-07-11 10:41:42 -04:00
5 changed files with 96 additions and 18 deletions
Showing only changes of commit 60406f9893 - Show all commits

View file

@ -0,0 +1,5 @@
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.9171 74.4333L74.1559 38.1342C76.724 35.5615 80.2279 34.9239 82.012 36.7033C83.7943 38.4839 83.1619 41.9888 80.5938 44.5622L44.3551 80.8595C41.7863 83.433 38.2831 84.0709 36.499 82.2904C34.716 80.5109 35.3483 77.006 37.9171 74.4333Z" fill="#C23B3B"/>
<path d="M80.8379 74.4333L44.5996 38.1342C42.0304 35.5615 38.5265 34.9239 36.7431 36.7033C34.9601 38.4839 35.5924 41.9888 38.1616 44.5622L74.4 80.8595C76.9681 83.433 80.472 84.0709 82.2554 82.2904C84.0384 80.5109 83.4061 77.006 80.8379 74.4333Z" fill="#C23B3B"/>
<path d="M59.5 0C43.7197 0 28.5856 6.26821 17.4278 17.4267C6.26891 28.5856 0 43.719 0 59.5C0 75.2803 6.26891 90.4144 17.4278 101.572C28.5856 112.731 43.7197 118.999 59.5 118.999C75.281 118.999 90.4144 112.73 101.572 101.572C112.731 90.4134 119 75.2803 119 59.5C119 43.7197 112.731 28.5856 101.572 17.4267C90.4144 6.26891 75.281 0 59.5 0ZM59.5 9.34303C72.8025 9.34303 85.5598 14.6275 94.9663 24.0337C104.373 33.4402 109.657 46.1968 109.657 59.5C109.657 72.8014 104.373 85.5591 94.9663 94.9653C85.5598 104.371 72.8025 109.656 59.5 109.656C46.1975 109.656 33.4409 104.371 24.0347 94.9653C14.6285 85.5591 9.34409 72.8014 9.34409 59.5C9.34409 46.1968 14.6285 33.4402 24.0347 24.0337C33.4409 14.6275 46.1975 9.34303 59.5 9.34303Z" fill="#C23B3B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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 (
<div className="relative mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">بازی</label>
<button
type="button"
disabled={!hasMatches}
onClick={() => setOpen((o) => !o)}
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right disabled:opacity-50"
>
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'}
{selectedMatch
? formatMatchLabel(selectedMatch, now)
: hasMatches
? 'بازی مورد نظر را انتخاب کنید'
: 'بازی فعالی موجود نیست'}
</span>
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
</button>
{open && (
{open && hasMatches && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">

View file

@ -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 (
<div className="relative px-4 mt-4">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<button
type="button"
disabled={!hasMatches}
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
>
<span className="text-gray-900">{selectedMatch ? formatMatchLabel(selectedMatch, now) : ''}</span>
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
</span>
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
</button>
{open && (
{open && hasMatches && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">

View file

@ -52,7 +52,13 @@ export default function Login() {
};
return (
<div className="flex min-h-screen flex-col justify-center px-6">
<form
onSubmit={(e) => {
e.preventDefault();
if (canContinue) handleContinue();
}}
className="flex min-h-screen flex-col justify-center px-6"
>
<h1 className="text-right text-2xl font-bold text-gray-900">
ورود به وینسو
</h1>
@ -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 && <p className="mt-2 text-right text-sm text-red-500">{error}</p>}
<button
type="button"
type="submit"
disabled={!canContinue}
onClick={handleContinue}
className="mt-4 w-full flex justify-center items-center h-12 rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? "در حال ارسال..." : "ادامه"}
@ -101,6 +107,6 @@ export default function Login() {
را می‌پذیرم.
</p>
</div>
</div>
</form>
);
}

View file

@ -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 (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
@ -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 "@<this site's domain> #<code>", 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 (
<div className="flex min-h-screen flex-col justify-center px-6">
<form
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) handleSubmit()
}}
className="flex min-h-screen flex-col justify-center px-6"
>
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
<div className="mt-2 flex items-center justify-start gap-2">
<span className="text-[#223D5D]">{phone}</span>
@ -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 && <p className="mt-2 text-center text-sm text-red-500">{error}</p>}
<button
type="button"
type="submit"
disabled={!canSubmit}
onClick={handleSubmit}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? 'در حال ورود...' : 'ورود'}
</button>
</div>
</form>
)
}