Накатил человеческий дизайн
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
|
||||
export function AuthLoading() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const phone = (location.state as { phone?: string })?.phone ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
// Имитируем авторизацию — через 2.8 секунды переходим на главную
|
||||
const t = setTimeout(() => navigate('/'), 2800);
|
||||
return () => clearTimeout(t);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background items-center justify-center px-6">
|
||||
{/* Анимированный логотип */}
|
||||
<div className="mb-12 flex flex-col items-center">
|
||||
<div className="relative mb-6">
|
||||
{/* Пульсирующие кольца */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full border-2 border-primary/20 animate-ping"
|
||||
style={{ animationDuration: '1.8s' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="w-14 h-14 rounded-full border-2 border-primary/30 animate-ping"
|
||||
style={{ animationDuration: '1.8s', animationDelay: '0.3s' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Центральный круг с логотипом */}
|
||||
<div className="relative w-20 h-20 rounded-full bg-primary flex items-center justify-center shadow-lg">
|
||||
<span className="text-primary-foreground font-bold text-xl tracking-wider">HAN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор загрузки */}
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-2 h-2 rounded-full bg-primary"
|
||||
style={{
|
||||
animation: 'bounce 1.2s ease-in-out infinite',
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Текст */}
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">Выполняем вход</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Проверяем данные{phone ? ` для ${phone}` : ''}...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Подсказка снизу */}
|
||||
<div className="absolute bottom-16 text-center px-8">
|
||||
<p className="text-xs text-muted-foreground/70 leading-relaxed">
|
||||
HAN — ваш персональный консультант по вопросам миграции в России
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||
40% { transform: translateY(-8px); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
export function AuthPhone() {
|
||||
const navigate = useNavigate();
|
||||
const [phone, setPhone] = useState('');
|
||||
|
||||
function formatPhone(raw: string) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 11);
|
||||
if (digits.length === 0) return '';
|
||||
let result = '+7';
|
||||
if (digits.length > 1) result += ' (' + digits.slice(1, 4);
|
||||
if (digits.length >= 4) result += ') ' + digits.slice(4, 7);
|
||||
if (digits.length >= 7) result += ' ' + digits.slice(7, 9);
|
||||
if (digits.length >= 9) result += ' ' + digits.slice(9, 11);
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const raw = e.target.value.replace(/\D/g, '');
|
||||
// Если начинается с 8, заменяем на 7
|
||||
const normalized = raw.startsWith('8') ? '7' + raw.slice(1) : raw.startsWith('7') ? raw : '7' + raw;
|
||||
setPhone(formatPhone(normalized));
|
||||
}
|
||||
|
||||
const isValid = phone.replace(/\D/g, '').length === 11;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (isValid) navigate('/auth/otp', { state: { phone } });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex-1 flex flex-col justify-between px-6 pt-16 pb-8">
|
||||
{/* Верхняя часть */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Логотип */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-end gap-1 mb-3">
|
||||
<span className="text-4xl font-bold tracking-tight text-foreground">HAN</span>
|
||||
<span className="w-2 h-2 rounded-full bg-primary mb-2" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Персональный консультант мигранта
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||
Добро пожаловать
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Введите номер телефона — отправим код подтверждения
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Форма */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Номер телефона</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={handleInput}
|
||||
placeholder="+7 (___) ___ __ __"
|
||||
className="w-full h-14 px-4 rounded-xl border border-border bg-input-background text-foreground text-base placeholder:text-muted-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
Россия · Код страны +7
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center gap-2.5 transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||
>
|
||||
Получить код
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Низ страницы */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Нажимая «Получить код», вы соглашаетесь с{' '}
|
||||
<span className="text-foreground/70 underline underline-offset-2">условиями использования</span>
|
||||
{' '}и{' '}
|
||||
<span className="text-foreground/70 underline underline-offset-2">политикой конфиденциальности</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import { Header } from '../components/Header';
|
||||
export function Root() {
|
||||
const location = useLocation();
|
||||
const isChat = location.pathname.startsWith('/chat/');
|
||||
const isAuth = location.pathname.startsWith('/auth/');
|
||||
|
||||
return (
|
||||
<div className="size-full flex flex-col bg-background">
|
||||
<div className="w-full max-w-[390px] mx-auto h-full flex flex-col">
|
||||
{/* Хедер показываем на всех страницах кроме чата */}
|
||||
{!isChat && <Header />}
|
||||
{/* Хедер скрываем на чате и экранах авторизации */}
|
||||
{!isChat && !isAuth && <Header />}
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Profile } from './pages/Profile';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { Calendar } from './pages/Calendar';
|
||||
import { NotificationDetail } from './pages/NotificationDetail';
|
||||
import { AuthPhone } from './pages/AuthPhone';
|
||||
import { AuthOtp } from './pages/AuthOtp';
|
||||
import { AuthLoading } from './pages/AuthLoading';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -18,6 +21,9 @@ export const router = createBrowserRouter([
|
||||
{ path: 'chat/:id', Component: Chat },
|
||||
{ path: 'calendar', Component: Calendar },
|
||||
{ path: 'notification/:id', Component: NotificationDetail },
|
||||
{ path: 'auth/phone', Component: AuthPhone },
|
||||
{ path: 'auth/otp', Component: AuthOtp },
|
||||
{ path: 'auth/loading', Component: AuthLoading },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user