Поправлен интерфейс
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { useState, useRef, useEffect, KeyboardEvent, ClipboardEvent } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { ArrowLeft, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
// Правильный код для демо
|
||||
const DEMO_CODE = '123456';
|
||||
|
||||
export function AuthOtp() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const phone = (location.state as { phone?: string })?.phone ?? '+7 (___) ___ __ __';
|
||||
|
||||
const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [countdown, setCountdown] = useState(59);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown === 0) { setCanResend(true); return; }
|
||||
const t = setTimeout(() => setCountdown(c => c - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
function handleChange(index: number, value: string) {
|
||||
const digit = value.replace(/\D/g, '').slice(-1);
|
||||
const next = [...digits];
|
||||
next[index] = digit;
|
||||
setDigits(next);
|
||||
setError(null);
|
||||
|
||||
if (digit && index < CODE_LENGTH - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Автоматическая проверка когда введены все цифры
|
||||
if (next.every(d => d !== '')) {
|
||||
verifyCode(next.join(''));
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Backspace') {
|
||||
if (digits[index]) {
|
||||
const next = [...digits];
|
||||
next[index] = '';
|
||||
setDigits(next);
|
||||
setError(null);
|
||||
} else if (index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
const next = [...digits];
|
||||
next[index - 1] = '';
|
||||
setDigits(next);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && index > 0) inputRefs.current[index - 1]?.focus();
|
||||
if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
||||
if (!pasted) return;
|
||||
const next = Array(CODE_LENGTH).fill('');
|
||||
pasted.split('').forEach((d, i) => { next[i] = d; });
|
||||
setDigits(next);
|
||||
setError(null);
|
||||
const focusIdx = Math.min(pasted.length, CODE_LENGTH - 1);
|
||||
inputRefs.current[focusIdx]?.focus();
|
||||
if (next.every(d => d !== '')) verifyCode(next.join(''));
|
||||
}
|
||||
|
||||
function verifyCode(code: string) {
|
||||
if (code === DEMO_CODE) {
|
||||
navigate('/auth/loading', { state: { phone } });
|
||||
} else {
|
||||
setError('Неверный код. Проверьте и попробуйте снова');
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 500);
|
||||
// Очищаем поля после ошибки
|
||||
setTimeout(() => {
|
||||
setDigits(Array(CODE_LENGTH).fill(''));
|
||||
inputRefs.current[0]?.focus();
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResend() {
|
||||
if (!canResend) return;
|
||||
setDigits(Array(CODE_LENGTH).fill(''));
|
||||
setError(null);
|
||||
setCountdown(59);
|
||||
setCanResend(false);
|
||||
inputRefs.current[0]?.focus();
|
||||
}
|
||||
|
||||
const isComplete = digits.every(d => d !== '');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col px-6 pt-14 pb-8 h-full">
|
||||
{/* Назад */}
|
||||
<button
|
||||
onClick={() => navigate('/auth/phone')}
|
||||
className="flex items-center gap-1.5 text-muted-foreground text-sm mb-10 -ml-1 self-start hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Назад
|
||||
</button>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||
Введите код
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Отправили SMS на номер{' '}
|
||||
<span className="text-foreground font-medium">{phone}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Поля ввода OTP */}
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className={`flex gap-2.5 justify-center ${shake ? 'animate-shake' : ''}`}
|
||||
style={shake ? { animation: 'shake 0.4s ease' } : undefined}
|
||||
>
|
||||
{digits.map((digit, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={el => { inputRefs.current[i] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={e => handleChange(i, e.target.value)}
|
||||
onKeyDown={e => handleKeyDown(i, e)}
|
||||
onPaste={handlePaste}
|
||||
className={`
|
||||
w-12 h-14 text-center text-xl font-semibold rounded-xl border transition-all
|
||||
focus:outline-none focus:ring-2
|
||||
${error
|
||||
? 'border-destructive bg-destructive/5 text-destructive focus:ring-destructive/20'
|
||||
: digit
|
||||
? 'border-primary/50 bg-primary/5 text-foreground focus:ring-primary/30 focus:border-primary/60'
|
||||
: 'border-border bg-input-background text-foreground focus:ring-primary/30 focus:border-primary/50'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Блок ошибки */}
|
||||
{error && (
|
||||
<div className="mt-4 flex items-start gap-2.5 bg-destructive/8 border border-destructive/25 rounded-xl px-4 py-3">
|
||||
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||
<p className="text-xs text-destructive/75 mt-0.5">
|
||||
Для демо введите код: <span className="font-mono font-semibold">123456</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Повторная отправка */}
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{canResend ? (
|
||||
<button
|
||||
onClick={handleResend}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Отправить код снова
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Отправить повторно через{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
0:{countdown.toString().padStart(2, '0')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка подтверждения */}
|
||||
<button
|
||||
disabled={!isComplete || !!error}
|
||||
onClick={() => isComplete && !error && verifyCode(digits.join(''))}
|
||||
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15% { transform: translateX(-6px); }
|
||||
30% { transform: translateX(6px); }
|
||||
45% { transform: translateX(-5px); }
|
||||
60% { transform: translateX(5px); }
|
||||
75% { transform: translateX(-3px); }
|
||||
90% { transform: translateX(3px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user