diff --git a/config/webpack.dev.js b/config/webpack.dev.js index 857c6e3..0cf1af3 100644 --- a/config/webpack.dev.js +++ b/config/webpack.dev.js @@ -9,6 +9,13 @@ module.exports = { open: true, port: "3000", historyApiFallback: true, + client: { + overlay: { + errors: false, + warnings: false, + runtimeErrors: false, + }, + }, }, module: { rules: require('./webpack.rules'), diff --git a/src/app/api/agent.api.ts b/src/app/api/agent.api.ts index 47dfef5..821db4e 100644 --- a/src/app/api/agent.api.ts +++ b/src/app/api/agent.api.ts @@ -7,7 +7,7 @@ import FRONTEND_ROUTES from '../common/constants/frontend-routes.constants'; import UserLoginStore from '../stores/user-login-store'; axios.defaults.baseURL = process.env.NODE_ENV === 'development' - ? 'https://localhost:5001/api' : 'https://app-streetcode-webapi-eu-prop-001-gsbqfwc2fdh6hhaw.polandcentral-01.azurewebsites.net/api'; + ? 'https://localhost:7146/api' : 'https://app-streetcode-webapi-eu-prop-001-gsbqfwc2fdh6hhaw.polandcentral-01.azurewebsites.net/api'; axios.interceptors.response.use( async (response) => response, diff --git a/src/app/api/user/user.api.ts b/src/app/api/user/user.api.ts index b93c295..e468205 100644 --- a/src/app/api/user/user.api.ts +++ b/src/app/api/user/user.api.ts @@ -2,13 +2,29 @@ import Agent from '@api/agent.api'; import { API_ROUTES } from '@/app/common/constants/api-routes.constants'; import { + LogoutRequest, RefreshTokenRequest, RefreshTokenResponce, UserLoginRequest, UserLoginResponce, + UserRegisterRequest, } from '@/models/user/user.model'; const UserApi = { - login: (loginParams:UserLoginRequest) => Agent.post(API_ROUTES.USERS.LOGIN, loginParams), - refreshToken: (token:RefreshTokenRequest) => Agent - .post(API_ROUTES.USERS.REFRESH_TOKEN, token), + register: (registerData: UserRegisterRequest) => Agent.post( + API_ROUTES.USERS.REGISTER, + registerData, + ), + login: (loginParams: UserLoginRequest) => Agent.post( + API_ROUTES.USERS.LOGIN, + loginParams, + ), + logout: (logoutData: LogoutRequest) => Agent.post( + API_ROUTES.USERS.LOGOUT, + logoutData, + ), + refreshToken: (token: RefreshTokenRequest) => Agent.post( + API_ROUTES.USERS.REFRESH_TOKEN, + token, + ), }; + export default UserApi; diff --git a/src/app/common/constants/api-routes.constants.ts b/src/app/common/constants/api-routes.constants.ts index c9e223b..923f13d 100644 --- a/src/app/common/constants/api-routes.constants.ts +++ b/src/app/common/constants/api-routes.constants.ts @@ -197,8 +197,10 @@ export const API_ROUTES = { DELETE: 'coordinate/delete', }, USERS: { - LOGIN: 'user/login', - REFRESH_TOKEN: 'user/refreshToken', + REGISTER: 'Users/Register', + LOGIN: 'Users/Login', + LOGOUT: 'Users/Logout', + REFRESH_TOKEN: 'Users/RefreshToken', }, EMAIL: { SEND: 'email/send', diff --git a/src/app/common/constants/frontend-routes.constants.ts b/src/app/common/constants/frontend-routes.constants.ts index b4656bd..815b239 100644 --- a/src/app/common/constants/frontend-routes.constants.ts +++ b/src/app/common/constants/frontend-routes.constants.ts @@ -20,6 +20,8 @@ const FRONTEND_ROUTES = { PARTNERS: '/partners-page', SUPPORT_US: '/support-us', NEWS: '/news', + LOGIN: '/login', + SIGNUP: '/signup', }, }; diff --git a/src/app/layout/header/HeaderBlock.component.tsx b/src/app/layout/header/HeaderBlock.component.tsx index a5c9d46..4e05428 100644 --- a/src/app/layout/header/HeaderBlock.component.tsx +++ b/src/app/layout/header/HeaderBlock.component.tsx @@ -12,6 +12,7 @@ import useEventListener from '@hooks/external/useEventListener.hook'; import useOnClickOutside from '@hooks/stateful/useClickOutside.hook'; import useToggle from '@hooks/stateful/useToggle.hook'; import HeaderDrawer from '@layout/header/HeaderDrawer/HeaderDrawer.component'; +import ProfileCircle from '@layout/header/ProfileCircle/ProfileCircle.component'; import HeaderSkeleton from '@layout/header/HeaderSkeleton/HeaderSkeleton.component'; import useMobx, { useModalContext } from '@stores/root-store'; @@ -86,6 +87,7 @@ const HeaderBlock = () => { style={isPageDimmed ? { zIndex: '-1' } : undefined} /> )} + + ); + } + + return ( + +
+ +
+
+ ); +}; + +export default observer(ProfileCircle); diff --git a/src/app/layout/header/ProfileCircle/ProfileCircle.styles.scss b/src/app/layout/header/ProfileCircle/ProfileCircle.styles.scss new file mode 100644 index 0000000..1755887 --- /dev/null +++ b/src/app/layout/header/ProfileCircle/ProfileCircle.styles.scss @@ -0,0 +1,65 @@ +@use "sass:color"; +@use "@sass/mixins/_utils.mixins.scss" as mut; + +.loginButton { + width: 77px; + height: 39px; + padding: 9px 20px; + border-radius: 10px; + border: 2px solid #DB3424; + background-color: transparent; + color: #DB3424; + font-size: 16px; + font-weight: 500; + cursor: pointer; + margin-right: 12px; + flex-shrink: 0; + + &:hover { + background-color: #DB3424; + color: white; + } +} + +.profileCircle { + @include mut.sized(40px, 40px); + @include mut.flex-centered(); + @include mut.full-rounded(50%); + + background-color: #F0F3F9; + margin-right: 30px; + cursor: pointer; + flex-shrink: 0; + + .profileIcon { + font-size: 20px; + color: #8C8C8C; + display: flex; + align-items: center; + justify-content: center; + margin-left: 6px; + } + + &:hover { + background-color: color.adjust(#F0F3F9, $lightness: -5%); + } +} + +@media screen and (max-width: 1024px) { + .loginButton { + width: 65px; + height: 34px; + padding: 7px 14px; + font-size: 14px; + margin-right: 8px; + } + + .profileCircle { + @include mut.sized(36px, 36px); + margin-right: px; + + .profileIcon { + font-size: 18px; + } + } +} diff --git a/src/app/router/ProtectedRoute.component.tsx b/src/app/router/ProtectedRoute.component.tsx new file mode 100644 index 0000000..c6e43d0 --- /dev/null +++ b/src/app/router/ProtectedRoute.component.tsx @@ -0,0 +1,23 @@ +import { observer } from 'mobx-react-lite'; +import { Navigate, useLocation } from 'react-router-dom'; + +import FRONTEND_ROUTES from '@/app/common/constants/frontend-routes.constants'; +import useMobx from '@/app/stores/root-store'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +const ProtectedRoute: React.FC = ({ children }) => { + const { userLoginStore } = useMobx(); + const location = useLocation(); + + if (!userLoginStore.isUserLoggedIn) { + // Redirect to login page, saving the attempted URL + return ; + } + + return <>{children}; +}; + +export default observer(ProtectedRoute); diff --git a/src/app/router/Routes.tsx b/src/app/router/Routes.tsx index 709868d..6146979 100644 --- a/src/app/router/Routes.tsx +++ b/src/app/router/Routes.tsx @@ -6,6 +6,8 @@ import StreetcodeContent from '@streetcode/Streetcode.component'; import NotFound from '@/features/AdditionalPages/NotFoundPage/NotFound.component'; import PartnersPage from '@/features/AdditionalPages/PartnersPage/Partners.component'; +import LoginPage from '@/features/AdditionalPages/LoginPage/LoginPage.component'; +import SignupPage from '@/features/AdditionalPages/SignupPage/SignupPage.component'; import AdminPage from '@/features/AdminPage/AdminPage.component'; import Partners from '@/features/AdminPage/PartnersPage/Partners.component'; import TeamPage from '@/features/AdminPage/TeamPage/TeamPage.component'; @@ -13,6 +15,7 @@ import StreetcodeCatalog from '@/features/StreetcodeCatalogPage/StreetcodeCatalo import NewsPage from '@/features/AdditionalPages/NewsPage/News.component'; import ContactUs from '@/features/AdditionalPages/ContactUsPage/ContanctUs.component'; import SupportPage from '@/features/AdditionalPages/SupportUsPage/SupportUs.component'; +import ProtectedRoute from './ProtectedRoute.component'; @@ -20,31 +23,33 @@ const router = createBrowserRouter(createRoutesFromElements( }> } + element={} /> } + element={} /> } + element={} /> } + element={} /> } /> + )} /> } /> } /> } /> } /> + } /> + } /> } /> } /> , diff --git a/src/app/stores/user-login-store.ts b/src/app/stores/user-login-store.ts index 6030214..6fb16d8 100644 --- a/src/app/stores/user-login-store.ts +++ b/src/app/stores/user-login-store.ts @@ -8,18 +8,28 @@ export default class UserLoginStore { private static tokenStorageName = 'token'; + private static refreshTokenStorageName = 'refreshToken'; + private static dateStorageName = 'expireAt'; public userLoginResponce?: UserLoginResponce; + public isUserLoggedIn: boolean = false; + private callback?:()=>void; public constructor() { makeAutoObservable(this); + // Check initial login state from localStorage + const token = UserLoginStore.getToken(); + const expireAt = UserLoginStore.getExpiredDate(); + this.isUserLoggedIn = !!token && expireAt > Date.now(); } private static getExpiredDate():number { - return Number(localStorage.getItem(UserLoginStore.dateStorageName)!); + const expireAt = localStorage.getItem(UserLoginStore.dateStorageName); + if (!expireAt) return 0; + return Number(expireAt); } private static setExpiredDate(date: string):void { @@ -34,8 +44,17 @@ export default class UserLoginStore { return localStorage.setItem(UserLoginStore.tokenStorageName, newToken); } + public static getRefreshToken() { + return localStorage.getItem(UserLoginStore.refreshTokenStorageName); + } + + public static setRefreshToken(refreshToken: string) { + return localStorage.setItem(UserLoginStore.refreshTokenStorageName, refreshToken); + } + private static clearToken() { localStorage.removeItem(UserLoginStore.tokenStorageName); + localStorage.removeItem(UserLoginStore.refreshTokenStorageName); } public setCallback(func:()=>void) { @@ -51,21 +70,38 @@ export default class UserLoginStore { clearTimeout(this.timeoutHandler); } localStorage.removeItem(UserLoginStore.tokenStorageName); + localStorage.removeItem(UserLoginStore.refreshTokenStorageName); localStorage.removeItem(UserLoginStore.dateStorageName); + this.userLoginResponce = undefined; + this.isUserLoggedIn = false; } - public logout() { + public async logout() { + const refreshToken = UserLoginStore.getRefreshToken(); + if (refreshToken) { + try { + await UserApi.logout({ refreshToken }); + } catch (e) { + console.log('Logout error:', e); + } + } this.clearUserData(); } public setUserLoginResponce(user:UserLoginResponce, func:()=>void) { try { - const timeNumber = (new Date(user.expireAt)).getTime(); + // If expireAt is not provided, set a default expiration of 24 hours + const expireAt = user.expireAt ? new Date(user.expireAt) : new Date(Date.now() + 24 * 60 * 60 * 1000); + const timeNumber = expireAt.getTime(); UserLoginStore.setExpiredDate(timeNumber.toString()); const expireForSeconds = timeNumber - new Date().getTime(); this.setCallback(func); this.userLoginResponce = user; UserLoginStore.setToken(user.token); + if (user.refreshToken) { + UserLoginStore.setRefreshToken(user.refreshToken); + } + this.isUserLoggedIn = true; if (expireForSeconds > 10000) { this.timeoutHandler = setTimeout(() => { if (this.callback) { diff --git a/src/features/AdditionalPages/LoginPage/LoginPage.component.tsx b/src/features/AdditionalPages/LoginPage/LoginPage.component.tsx new file mode 100644 index 0000000..395ab35 --- /dev/null +++ b/src/features/AdditionalPages/LoginPage/LoginPage.component.tsx @@ -0,0 +1,176 @@ +import './LoginPage.styles.scss'; + +import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Input, message } from 'antd'; + +import UserApi from '@/app/api/user/user.api'; +import FRONTEND_ROUTES from '@/app/common/constants/frontend-routes.constants'; +import useMobx from '@/app/stores/root-store'; + +const LoginPage = () => { + const navigate = useNavigate(); + const { userLoginStore } = useMobx(); + const [messageApi, contextHolder] = message.useMessage(); + const [showPassword, setShowPassword] = useState(false); + const [rememberMe, setRememberMe] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [formData, setFormData] = useState({ + email: '', + password: '', + }); + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + const showSuccessMessage = (text: string) => { + messageApi.open({ + type: 'success', + content: text, + className: 'custom-success-message', + duration: 3, + }); + }; + + const showErrorMessage = (text: string) => { + messageApi.open({ + type: 'error', + content: text, + duration: 3, + }); + }; + + const handleSubmit = async () => { + if (!formData.email || !formData.password) { + showErrorMessage('Будь ласка, заповніть всі поля'); + return; + } + + setIsLoading(true); + try { + const response = await UserApi.login({ + email: formData.email, + password: formData.password, + }); + + userLoginStore.setUserLoginResponce(response, () => { + userLoginStore.refreshToken(); + }); + + showSuccessMessage('Ви успішно увійшли в систему.'); + setTimeout(() => navigate(FRONTEND_ROUTES.BASE), 1500); + } catch (error: any) { + console.error('Login error:', error); + showErrorMessage('Невірний email або пароль'); + } finally { + setIsLoading(false); + } + }; + + const handleGoogleLogin = () => { + console.log('Google login clicked'); + // Implement Google OAuth login here + }; + + const handleForgotPassword = () => { + console.log('Forgot password clicked'); + // Implement forgot password flow here + }; + + return ( +
+ {contextHolder} +
+
+

Вхід

+

Введіть свої дані для входу

+
+ +
+
+ +
+ handleInputChange('email', e.target.value)} + maxLength={256} + /> + {formData.email.length}/256 +
+
+ +
+ +
+ handleInputChange('password', e.target.value)} + placeholder="********" + /> + setShowPassword(!showPassword)} + > + {showPassword ? : } + +
+
+ +
+ + Забули пароль? + +
+ setRememberMe(e.target.checked)} + /> + Запам'ятати мене +
+
+ + + +
+ Немає облікового запису?{' '} + navigate(FRONTEND_ROUTES.OTHER_PAGES.SIGNUP)}> + Зареєструватися + +
+ +
+
+ або продовжити через +
+
+ + +
+
+
+ ); +}; + +export default observer(LoginPage); diff --git a/src/features/AdditionalPages/LoginPage/LoginPage.styles.scss b/src/features/AdditionalPages/LoginPage/LoginPage.styles.scss new file mode 100644 index 0000000..0907069 --- /dev/null +++ b/src/features/AdditionalPages/LoginPage/LoginPage.styles.scss @@ -0,0 +1,312 @@ +@use "@sass/_utils.functions.scss" as f; +@use "@sass/variables/_variables.colors.scss" as c; +@use "@sass/mixins/_utils.mixins.scss" as mut; + +.loginPage { + @include mut.flexed($justify-content: center, $align-items: center); + min-height: calc(100vh - 82px - 244px); + padding: 80px 20px; + background: #FCFCFC; + margin-top: 82px; +} + +.loginCard { + @include mut.flexed($direction: column, $align-items: center, $gap: 35); + width: 100%; + max-width: 600px; + padding: 48px 60px; + background: #FFFFFF; + box-shadow: 0px 5.78936px 14.0599px 1.6541px rgba(91, 91, 91, 0.25); + border-radius: 60px; +} + +.loginHeader { + text-align: center; + + h1 { + font-family: 'Roboto', sans-serif; + font-weight: 500; + font-size: 24px; + line-height: 28px; + color: #1D1F23; + margin-bottom: 10px; + } + + p { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #535353; + margin: 0; + } +} + +.loginForm { + @include mut.flexed($direction: column, $gap: 20); + width: 100%; + max-width: 480px; +} + +.inputGroup { + @include mut.flexed($direction: column, $gap: 4); + width: 100%; + + label { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #767676; + } +} + +.inputWrapper { + position: relative; + width: 100%; + + .ant-input { + box-sizing: border-box; + padding: 9px 16px; + padding-right: 50px; + height: 44px; + border: 1px solid #A3A3A3; + border-radius: 10px; + font-family: 'Roboto', sans-serif; + font-size: 16px; + line-height: 160%; + color: #1D1F23; + + &:focus, &:hover { + border-color: #DB3424; + box-shadow: none; + } + } + + .charCount { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-family: 'Roboto', sans-serif; + font-size: 16px; + line-height: 160%; + color: #A3A3A3; + } + + .passwordToggle { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + color: #767676; + font-size: 18px; + + &:hover { + color: #535353; + } + } +} + +.loginOptions { + @include mut.flexed($justify-content: space-between, $align-items: center); + width: 100%; + + .forgotPassword { + font-family: 'Roboto', sans-serif; + font-weight: 500; + font-size: 16px; + line-height: 24px; + color: #891F16; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .rememberMe { + @include mut.flexed($align-items: center, $gap: 10); + + .ant-checkbox-checked .ant-checkbox-inner { + background-color: #891F16; + border-color: #891F16; + } + + span { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #535353; + } + } +} + +.submitButton { + width: 100%; + height: 65px; + background: #E04031; + border: 1px solid rgba(209, 209, 209, 0.819608); + border-radius: 10px; + font-family: 'Roboto', sans-serif; + font-weight: 700; + font-size: 20px; + line-height: 23px; + color: #FFFFFF; + margin-top: 20px; + + &:hover { + background: #c9362a !important; + } + + &:disabled { + background: #cccccc; + cursor: not-allowed; + } +} + +.switchMode { + text-align: center; + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 18px; + line-height: 21px; + color: rgba(83, 83, 83, 0.78); + + span { + color: #DB3424; + cursor: pointer; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } +} + +.divider { + @include mut.flexed($align-items: center, $gap: 8); + width: 100%; + + .line { + flex: 1; + height: 1px; + background: #D9D9D9; + } + + span { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: rgba(83, 83, 83, 0.78); + white-space: nowrap; + } +} + +.googleButton { + @include mut.flexed($align-items: center, $justify-content: center, $gap: 5); + padding: 9px 24px; + width: 153px; + height: 54px; + background: #F1F1F1; + border: none; + border-radius: 10px; + cursor: pointer; + align-self: center; + transition: background-color 0.3s ease; + + &:hover { + background: #E5E5E5; + } + + .googleIcon { + width: 36px; + height: 36px; + } + + span { + font-family: 'Roboto', sans-serif; + font-weight: 700; + font-size: 20px; + line-height: 23px; + color: rgba(0, 0, 0, 0.61); + } +} + +@media screen and (max-width: 768px) { + .loginCard { + padding: 40px 24px; + border-radius: 30px; + } + + .loginHeader { + h1 { + font-size: 20px; + } + + p { + font-size: 14px; + } + } + + .loginOptions { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .submitButton { + height: 50px; + font-size: 18px; + } + + .switchMode { + font-size: 16px; + } + + .googleButton { + width: 130px; + height: 48px; + + .googleIcon { + width: 28px; + height: 28px; + } + + span { + font-size: 16px; + } + } +} + +// Custom success message styling +.custom-success-message { + .ant-message-notice-content { + padding: 16px 24px; + background: #FFFFFF; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15); + border-radius: 10px; + } + + .ant-message-success { + display: flex; + align-items: center; + gap: 12px; + + .anticon { + color: #04A64D; + font-size: 24px; + } + + span { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #1D1F23; + } + } +} diff --git a/src/features/AdditionalPages/SignupPage/SignupPage.component.tsx b/src/features/AdditionalPages/SignupPage/SignupPage.component.tsx new file mode 100644 index 0000000..8f40861 --- /dev/null +++ b/src/features/AdditionalPages/SignupPage/SignupPage.component.tsx @@ -0,0 +1,241 @@ +import './SignupPage.styles.scss'; + +import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Input, message } from 'antd'; + +import UserApi from '@/app/api/user/user.api'; +import FRONTEND_ROUTES from '@/app/common/constants/frontend-routes.constants'; +import { UserRole } from '@/models/user/user.model'; + +const SignupPage = () => { + const navigate = useNavigate(); + const [messageApi, contextHolder] = message.useMessage(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [agreeToPolicy, setAgreeToPolicy] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + surname: '', + email: '', + password: '', + confirmPassword: '', + }); + + const [passwordStrength, setPasswordStrength] = useState({ + hasLength: false, + hasUppercase: false, + hasLowercase: false, + hasNumber: false, + hasSpecial: false, + }); + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + + if (field === 'password') { + setPasswordStrength({ + hasLength: value.length >= 8 && value.length <= 20, + hasUppercase: /[A-Z]/.test(value), + hasLowercase: /[a-z]/.test(value), + hasNumber: /[0-9]/.test(value), + hasSpecial: /[!@#$%^&*(),.?":{}|<>]/.test(value), + }); + } + }; + + const getPasswordStrengthBars = () => { + const strength = Object.values(passwordStrength).filter(Boolean).length; + return strength; + }; + + const showSuccessMessage = (text: string) => { + messageApi.open({ + type: 'success', + content: text, + className: 'custom-success-message', + duration: 3, + }); + }; + + const showErrorMessage = (text: string) => { + messageApi.open({ + type: 'error', + content: text, + duration: 3, + }); + }; + + const validateForm = (): boolean => { + if (!formData.name || !formData.surname || !formData.email || !formData.password) { + showErrorMessage('Будь ласка, заповніть всі обов\'язкові поля'); + return false; + } + if (formData.password !== formData.confirmPassword) { + showErrorMessage('Паролі не співпадають'); + return false; + } + if (!Object.values(passwordStrength).every(Boolean)) { + showErrorMessage('Пароль не відповідає вимогам безпеки'); + return false; + } + return true; + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + setIsLoading(true); + try { + await UserApi.register({ + name: formData.name, + surname: formData.surname, + email: formData.email, + userName: formData.email, // Using email as username + password: formData.password, + phoneNumber: '', // Optional, can add field later + role: UserRole.User, + }); + + showSuccessMessage('Ваш акаунт успішно створено.'); + setTimeout(() => navigate(FRONTEND_ROUTES.OTHER_PAGES.LOGIN), 1500); + } catch (error: any) { + console.error('Registration error:', error); + showErrorMessage('Помилка реєстрації. Можливо, цей email вже використовується.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {contextHolder} +
+
+

Реєстрація

+

Створіть свій обліковий запис

+
+ +
+
+ +
+ handleInputChange('name', e.target.value)} + maxLength={50} + /> + {formData.name.length}/50 +
+
+ +
+ +
+ handleInputChange('surname', e.target.value)} + maxLength={50} + /> + {formData.surname.length}/50 +
+
+ +
+ +
+ handleInputChange('email', e.target.value)} + maxLength={256} + /> + {formData.email.length}/256 +
+
+ +
+ +
+ handleInputChange('password', e.target.value)} + placeholder="********" + /> + setShowPassword(!showPassword)} + > + {showPassword ? : } + +
+
+ Від 8 до 20 символів, велика і мала літера, цифра, спеціальний символ. +
+
+ {[1, 2, 3, 4, 5].map((bar) => ( +
= bar ? 'active' : ''}`} + /> + ))} +
+
+ +
+ +
+ handleInputChange('confirmPassword', e.target.value)} + placeholder="********" + /> + setShowConfirmPassword(!showConfirmPassword)} + > + {showConfirmPassword ? : } + +
+
+ +
+ setAgreeToPolicy(e.target.checked)} + /> + + Я погоджуюсь з{' '} + + Політикою конфеденсійності + + +
+ + + +
+ У мене вже є акаунт.{' '} + navigate(FRONTEND_ROUTES.OTHER_PAGES.LOGIN)}>Увійти +
+
+
+
+ ); +}; + +export default observer(SignupPage); diff --git a/src/features/AdditionalPages/SignupPage/SignupPage.styles.scss b/src/features/AdditionalPages/SignupPage/SignupPage.styles.scss new file mode 100644 index 0000000..3c37fca --- /dev/null +++ b/src/features/AdditionalPages/SignupPage/SignupPage.styles.scss @@ -0,0 +1,264 @@ +@use "@sass/_utils.functions.scss" as f; +@use "@sass/variables/_variables.colors.scss" as c; +@use "@sass/mixins/_utils.mixins.scss" as mut; + +.signupPage { + @include mut.flexed($justify-content: center, $align-items: center); + min-height: calc(100vh - 82px - 244px); + padding: 80px 20px; + background: #FCFCFC; + margin-top: 82px; +} + +.signupCard { + @include mut.flexed($direction: column, $align-items: center, $gap: 35); + width: 100%; + max-width: 600px; + padding: 60px; + background: #FFFFFF; + box-shadow: 0px 5.78936px 14.0599px 1.6541px rgba(91, 91, 91, 0.25); + border-radius: 50px; +} + +.signupHeader { + text-align: center; + + h1 { + font-family: 'Roboto', sans-serif; + font-weight: 500; + font-size: 24px; + line-height: 28px; + color: #1D1F23; + margin-bottom: 10px; + } + + p { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #535353; + margin: 0; + } +} + +.signupForm { + @include mut.flexed($direction: column, $gap: 20); + width: 100%; + max-width: 480px; +} + +.inputGroup { + @include mut.flexed($direction: column, $gap: 4); + width: 100%; + + label { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #767676; + } +} + +.inputWrapper { + position: relative; + width: 100%; + + .ant-input { + box-sizing: border-box; + padding: 9px 16px; + padding-right: 50px; + height: 44px; + border: 1px solid #A3A3A3; + border-radius: 10px; + font-family: 'Roboto', sans-serif; + font-size: 16px; + line-height: 160%; + color: #1D1F23; + + &:focus, &:hover { + border-color: #DB3424; + box-shadow: none; + } + } + + .charCount { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-family: 'Roboto', sans-serif; + font-size: 16px; + line-height: 160%; + color: #A3A3A3; + } + + .passwordToggle { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + color: #767676; + font-size: 18px; + + &:hover { + color: #535353; + } + } +} + +.passwordHint { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 14px; + line-height: 140%; + color: #1D1F23; + margin-top: 4px; +} + +.strengthBars { + @include mut.flexed($gap: 4); + width: 100%; + margin-top: 4px; + + .bar { + flex: 1; + height: 4px; + background: #E0E0E0; + border-radius: 4px; + transition: background-color 0.3s ease; + + &.active { + background: #04A64D; + } + } +} + +.policyCheckbox { + @include mut.flexed($align-items: center, $gap: 10); + + .ant-checkbox-wrapper { + margin: 0; + } + + .ant-checkbox-checked .ant-checkbox-inner { + background-color: #891F16; + border-color: #891F16; + } + + span { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #535353; + + a { + color: #DB3424; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +.submitButton { + width: 100%; + height: 65px; + background: #E04031; + border: 1px solid rgba(209, 209, 209, 0.819608); + border-radius: 10px; + font-family: 'Roboto', sans-serif; + font-weight: 700; + font-size: 20px; + line-height: 23px; + color: #FFFFFF; + margin-top: 20px; + + &:hover { + background: #c9362a !important; + } + + &:disabled { + background: #cccccc; + cursor: not-allowed; + } +} + +.switchMode { + text-align: center; + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 18px; + line-height: 21px; + color: rgba(83, 83, 83, 0.78); + + span { + color: #DB3424; + cursor: pointer; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } +} + +@media screen and (max-width: 768px) { + .signupCard { + padding: 40px 24px; + border-radius: 30px; + } + + .signupHeader { + h1 { + font-size: 20px; + } + + p { + font-size: 14px; + } + } + + .submitButton { + height: 50px; + font-size: 18px; + } + + .switchMode { + font-size: 16px; + } +} + +// Custom success message styling +.custom-success-message { + .ant-message-notice-content { + padding: 16px 24px; + background: #FFFFFF; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.15); + border-radius: 10px; + } + + .ant-message-success { + display: flex; + align-items: center; + gap: 12px; + + .anticon { + color: #04A64D; + font-size: 24px; + } + + span { + font-family: 'Roboto', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: #1D1F23; + } + } +} diff --git a/src/models/user/user.model.ts b/src/models/user/user.model.ts index 9543a97..3a67ad0 100644 --- a/src/models/user/user.model.ts +++ b/src/models/user/user.model.ts @@ -3,21 +3,38 @@ export default interface User { name:string; surname:string; email:string; - login:string; + userName:string; password:string; - userRole:UserRole; + phoneNumber:string; + role:UserRole; +} + +export interface UserRegisterRequest { + name: string; + surname: string; + email: string; + userName: string; + password: string; + phoneNumber: string; + role: UserRole; } export interface UserLoginRequest { - login:string; + email:string; password:string; } + export interface UserLoginResponce { user:User; token:string; + refreshToken:string; expireAt:Date; } +export interface LogoutRequest { + refreshToken:string; +} + export interface RefreshTokenRequest { token:string; } @@ -28,7 +45,8 @@ export interface RefreshTokenResponce { } export enum UserRole { - MainAdministrator, - Administrator, - Moderator, + User = 0, + MainAdministrator = 1, + Administrator = 2, + Moderator = 3, }