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

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
+78
View File
@@ -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>
);
}
+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>
);
}
+103
View File
@@ -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>
);
}
+3 -2
View File
@@ -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
View File
@@ -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 },
],
},
]);