Накатил человеческий дизайн

This commit is contained in:
mi
2026-07-21 16:01:24 +03:00
parent 9cfc157124
commit 627371b221
35 changed files with 2415 additions and 293 deletions
+218
View File
@@ -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>
);
}