From b62da33ec4ba254f8941d958fd9461ab26439d79 Mon Sep 17 00:00:00 2001 From: Oleg Urin Date: Sat, 25 Oct 2025 22:24:36 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D0=B8,=20=D0=BF=D0=B5=D1=80=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BB=20=D0=B2=D0=BE=D1=80=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/src/App.css | 42 ---- front/src/index.css | 51 ++-- front/src/main.tsx | 1 - front/src/pages/LoginPage/LoginPage.css | 290 ++++++++++++++++++++++ front/src/pages/LoginPage/LoginPage.tsx | 304 +++++++++++++++++------- 5 files changed, 538 insertions(+), 150 deletions(-) diff --git a/front/src/App.css b/front/src/App.css index b9d355d..e69de29 100644 --- a/front/src/App.css +++ b/front/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/front/src/index.css b/front/src/index.css index 08a3ac9..a4f6453 100644 --- a/front/src/index.css +++ b/front/src/index.css @@ -1,5 +1,7 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; line-height: 1.5; font-weight: 400; @@ -11,15 +13,14 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + --purple: 7700FF + --black } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +#root { + max-width: 1280px; + margin: 0 auto; } body { @@ -28,14 +29,14 @@ body { place-items: center; min-width: 320px; min-height: 100vh; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: linear-gradient(135deg, #7700ff 0%, #ff4f12 100%);; + } -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { +/*button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; @@ -52,9 +53,9 @@ button:hover { button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; -} +}*/ -@media (prefers-color-scheme: light) { +/*@media (prefers-color-scheme: light) { :root { color: #213547; background-color: #ffffff; @@ -65,4 +66,22 @@ button:focus-visible { button { background-color: #f9f9f9; } +}*/ + +button { + font-family: inherit; } + +input { + font-family: inherit; +} + +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; +} \ No newline at end of file diff --git a/front/src/main.tsx b/front/src/main.tsx index 4812a17..4d20e55 100644 --- a/front/src/main.tsx +++ b/front/src/main.tsx @@ -1,7 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' import { BrowserRouter } from 'react-router' import AppRoutes from './app/routes.tsx' import { Provider } from 'react-redux' diff --git a/front/src/pages/LoginPage/LoginPage.css b/front/src/pages/LoginPage/LoginPage.css index e69de29..1963ce6 100644 --- a/front/src/pages/LoginPage/LoginPage.css +++ b/front/src/pages/LoginPage/LoginPage.css @@ -0,0 +1,290 @@ +.login-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.login-logo { + margin-bottom: 40px; + text-align: center; +} + +.login-logo-image { + max-width: 200px; + max-height: 80px; +} + +.login-form-card { + width: 100%; + max-width: 400px; + background: #ffffff; + border-radius: 16px; + box-shadow: 0 12px 40px rgba(16, 24, 40, 0.15); + padding: 40px; + border: 1px solid rgba(16, 24, 40, 0.1); +} + +.login-title { + text-align: center; + margin-bottom: 32px; + color: #101828; + font-weight: 700; + font-size: 28px; + line-height: 1.2; +} + +.login-error-alert { + background: #fff2f0; + border: 1px solid #ffccc7; + color: #a8071a; + padding: 14px 16px; + border-radius: 10px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 500; +} + +.login-error-alert:before { + content: '⚠️'; + font-size: 16px; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-label { + font-weight: 600; + color: #101828; + font-size: 14px; + letter-spacing: -0.2px; +} + +.form-input { + padding: 14px 16px; + border: 2px solid #e4e7ec; + border-radius: 10px; + font-size: 16px; + transition: all 0.3s ease; + background: #ffffff; + color: #101828; +} + +.form-input::placeholder { + color: #98a2b3; +} + +.form-input:focus { + outline: none; + border-color: #7700ff; + box-shadow: 0 0 0 4px rgba(119, 0, 255, 0.1); +} + +.form-input.error { + border-color: #ff4f12; + box-shadow: 0 0 0 4px rgba(255, 79, 18, 0.1); +} + +.form-error { + color: #ff4f12; + font-size: 13px; + margin-top: 6px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; +} + +.form-error:before { + content: '•'; + font-size: 16px; +} + +.checkbox-group { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + padding: 8px 0; +} + +.checkbox-input { + width: 20px; + height: 20px; + cursor: pointer; + border: 2px solid #d0d5dd; + border-radius: 6px; + background: #ffffff; + transition: all 0.2s ease; + position: relative; +} + +.checkbox-input:checked { + background: #7700ff; + border-color: #7700ff; +} + +.checkbox-input:checked:after { + content: '✓'; + position: absolute; + color: #ffffff; + font-size: 14px; + font-weight: bold; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.checkbox-input:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(119, 0, 255, 0.1); +} + +.checkbox-label { + font-size: 14px; + color: #101828; + cursor: pointer; + font-weight: 500; + user-select: none; +} + +.login-submit-button { + background: linear-gradient(135deg, #7700ff 0%, #ff4f12 100%); + color: #ffffff; + border: none; + padding: 16px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 16px; + letter-spacing: -0.2px; + position: relative; + overflow: hidden; +} + +.login-submit-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(119, 0, 255, 0.3); +} + +.login-submit-button:active:not(:disabled) { + transform: translateY(0); +} + +.login-submit-button:disabled { + background: #d0d5dd; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.login-submit-button.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid transparent; + border-top: 2px solid #ffffff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.login-forgot-password { + text-align: center; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #eaecf0; +} + +.forgot-password-link { + color: #7700ff; + text-decoration: none; + font-size: 14px; + font-weight: 600; + background: none; + border: none; + cursor: pointer; + transition: all 0.2s ease; + padding: 8px 16px; + border-radius: 8px; +} + +.forgot-password-link:hover { + color: #ff4f12; + background: rgba(119, 0, 255, 0.05); + text-decoration: none; +} + +.forgot-password-link:disabled { + color: #98a2b3; + cursor: not-allowed; + background: none; +} + +/* Анимация появления формы */ +.login-form-card { + animation: slideUp 0.6s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Адаптивность */ +@media (max-width: 480px) { + .login-form-card { + padding: 32px 24px; + margin: 0 16px; + } + + .login-logo { + margin-bottom: 32px; + } + + .login-logo-image { + max-width: 160px; + max-height: 60px; + } + + .login-title { + font-size: 24px; + margin-bottom: 28px; + } + + .form-input { + padding: 12px 14px; + } +} \ No newline at end of file diff --git a/front/src/pages/LoginPage/LoginPage.tsx b/front/src/pages/LoginPage/LoginPage.tsx index d10263b..54d4500 100644 --- a/front/src/pages/LoginPage/LoginPage.tsx +++ b/front/src/pages/LoginPage/LoginPage.tsx @@ -1,39 +1,142 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Form, Input, Button, Checkbox, Alert, Card, Spin, Image } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { loginThunk } from "../../features/auth/authThunks"; import { clearError } from '../../features/auth/authSlice'; +import './LoginPage.css' -interface LoginForm { +interface FormData { email: string; password: string; rememberMe: boolean; } +interface FormErrors { + email?: string; + password?: string; +} + const LoginPage: React.FC = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const { loading, error, token } = useAppSelector((state) => state.auth); - const [form] = Form.useForm(); + const [formData, setFormData] = useState({ + email: '', + password: '', + rememberMe: false + }); + + const [errors, setErrors] = useState({}); + const [touched, setTouched] = useState<{ email: boolean; password: boolean }>({ + email: false, + password: false + }); + // Редирект если пользователь уже авторизован useEffect(() => { if (token) { - navigate('/'); + navigate('/data-collection'); } }, [token, navigate]); - const handleSubmit = async (values: LoginForm) => { - try { - const result = await dispatch(loginThunk(values)).unwrap(); - - if (result.token) { - navigate('/data-collection'); + // Валидация email + const validateEmail = (email: string): string | undefined => { + if (!email) { + return 'Пожалуйста, введите email'; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return 'Введите корректный email адрес'; + } + return undefined; + }; + + // Валидация пароля + const validatePassword = (password: string): string | undefined => { + if (!password) { + return 'Пожалуйста, введите пароль'; + } + if (password.length < 8) { + return 'Пароль должен содержать минимум 8 символов'; + } + return undefined; + }; + + // Общая валидация формы + const validateForm = (): boolean => { + const newErrors: FormErrors = { + email: validateEmail(formData.email), + password: validatePassword(formData.password) + }; + + setErrors(newErrors); + return !newErrors.email && !newErrors.password; + }; + + // Обработчик изменения полей + const handleInputChange = (field: keyof FormData) => ( + e: React.ChangeEvent + ) => { + const value = field === 'rememberMe' ? e.target.checked : e.target.value; + + setFormData(prev => ({ + ...prev, + [field]: value + })); + + // Валидация при изменении (только для touched полей) + if (touched[field as keyof typeof touched]) { + if (field === 'email') { + setErrors(prev => ({ + ...prev, + email: validateEmail(value as string) + })); + } else if (field === 'password') { + setErrors(prev => ({ + ...prev, + password: validatePassword(value as string) + })); + } + } + }; + + // Обработчик потери фокуса + const handleBlur = (field: keyof typeof touched) => () => { + setTouched(prev => ({ ...prev, [field]: true })); + + // Валидация при потере фокуса + if (field === 'email') { + setErrors(prev => ({ + ...prev, + email: validateEmail(formData.email) + })); + } else if (field === 'password') { + setErrors(prev => ({ + ...prev, + password: validatePassword(formData.password) + })); + } + }; + + // Обработчик отправки формы + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Помечаем все поля как touched при отправке + setTouched({ email: true, password: true }); + + if (validateForm()) { + try { + const result = await dispatch(loginThunk(formData)).unwrap(); + + if (result.token) { + navigate('/data-collection'); + } + } catch (error) { + console.error('Login error:', error); } - } catch (error) { - console.error('Login error:', error); } }; @@ -43,106 +146,125 @@ const LoginPage: React.FC = () => { return (
+ {/* Логотип компании */}
- Company Logo { + e.currentTarget.src = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iODAiIGZpbGw9IiNmZmYiLz48dGV4dCB4PSIxMDAiIHk9IjQwIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiMzMzMiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkxPR08gQ09NUEFOWTwvdGV4dD48L3N2Zz4="; + }} />
- + {/* Форма авторизации */} +

Вход в систему

+ {/* Блок ошибок */} {error && ( - dispatch(clearError())} - className="login-error-alert" - /> +
+ {error} + +
)} -
- - } + + {/* Поле ввода email */} +
+ + - - - - } - placeholder="Введите пароль" - size="large" - /> - - - - Запомнить меня - - - - - - + /> + {errors.email && ( +
{errors.email}
+ )} +
+ {/* Поле ввода пароля */} +
+ + + {errors.password && ( +
{errors.password}
+ )} +
+ + {/* Чекбокс "Запомнить меня" */} + + + {/* Кнопка входа */} + + + + {/* Ссылка "Забыли пароль?" */}
- +
- +
); };