From de8d8cf0f022f71437b1f1fb8dc7196768a88169 Mon Sep 17 00:00:00 2001 From: Maria Bezdudnaya Date: Fri, 11 Apr 2025 20:29:15 +0500 Subject: [PATCH] add task 4 --- src/app/article/index.js | 5 +- src/app/basket/index.js | 3 - src/app/index.js | 23 ++++- src/app/login/index.js | 70 +++++++++++++ src/app/main/index.js | 26 ++--- src/app/profile/index.js | 40 ++++++++ src/app/protected-route/index.js | 25 +++++ src/components/article-card/index.js | 29 +++--- src/components/auth-view/index.js | 39 ++++++++ src/components/auth-view/style.css | 33 +++++++ src/components/basket-tool/index.js | 22 ++--- src/components/basket-total/index.js | 11 +-- src/components/button/index.js | 4 +- src/components/button/style.css | 5 +- src/components/controls/index.js | 6 +- src/components/head/index.js | 5 +- src/components/input/index.js | 13 +-- src/components/item-basket/index.js | 22 ++--- src/components/item/index.js | 20 ++-- src/components/list/index.js | 10 +- src/components/login-form/index.js | 49 +++++++++ src/components/login-form/style.css | 50 ++++++++++ src/components/menu/index.js | 15 +-- src/components/modal-layout/index.js | 11 +-- src/components/page-layout/index.js | 4 +- src/components/pagination/index.js | 52 +++++----- src/components/pagination/style.css | 1 + src/components/select/index.js | 22 +++-- src/components/select/style.css | 7 +- src/components/side-layout/index.js | 2 - src/components/spinner/index.js | 2 - src/components/user-info/index.js | 38 +++++++ src/components/user-info/style.css | 32 ++++++ src/containers/auth/index.js | 34 +++++++ src/containers/catalog-filter/index.js | 72 +++++++++----- src/containers/catalog-list/index.js | 15 ++- src/containers/locale-select/index.js | 4 +- src/hooks/use-title.js | 22 +++++ src/hooks/use-translate.js | 2 +- src/i18n/context.js | 30 +++--- src/i18n/translations/en.json | 21 +++- src/i18n/translations/ru.json | 21 +++- src/store/basket/index.js | 29 ++++-- src/store/catalog/index.js | 69 +++++++------ src/store/categories/index.js | 25 +++++ src/store/exports.js | 2 + src/store/locale/index.js | 3 +- src/store/login/index.js | 132 +++++++++++++++++++++++++ 48 files changed, 910 insertions(+), 267 deletions(-) create mode 100644 src/app/login/index.js create mode 100644 src/app/profile/index.js create mode 100644 src/app/protected-route/index.js create mode 100644 src/components/auth-view/index.js create mode 100644 src/components/auth-view/style.css create mode 100644 src/components/login-form/index.js create mode 100644 src/components/login-form/style.css create mode 100644 src/components/user-info/index.js create mode 100644 src/components/user-info/style.css create mode 100644 src/containers/auth/index.js create mode 100644 src/hooks/use-title.js create mode 100644 src/store/categories/index.js create mode 100644 src/store/login/index.js diff --git a/src/app/article/index.js b/src/app/article/index.js index 92772d30e..9be587859 100644 --- a/src/app/article/index.js +++ b/src/app/article/index.js @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import useStore from '../../hooks/use-store'; import useSelector from '../../hooks/use-selector'; @@ -9,6 +9,7 @@ import Head from '../../components/head'; import Navigation from '../../containers/navigation'; import Spinner from '../../components/spinner'; import ArticleCard from '../../components/article-card'; +import AuthContainer from '../../containers/auth'; import LocaleSelect from '../../containers/locale-select'; /** @@ -32,12 +33,12 @@ function Article() { const { t } = useTranslate(); const callbacks = { - // Добавление в корзину addToBasket: useCallback(_id => store.actions.basket.addToBasket(_id), [store]), }; return ( <> + diff --git a/src/app/basket/index.js b/src/app/basket/index.js index adcf54c63..3a08fbe92 100644 --- a/src/app/basket/index.js +++ b/src/app/basket/index.js @@ -1,7 +1,6 @@ import { memo, useCallback } from 'react'; import useStore from '../../hooks/use-store'; import useSelector from '../../hooks/use-selector'; -import useInit from '../../hooks/use-init'; import useTranslate from '../../hooks/use-translate'; import ItemBasket from '../../components/item-basket'; import List from '../../components/list'; @@ -21,9 +20,7 @@ function Basket() { })); const callbacks = { - // Удаление из корзины removeFromBasket: useCallback(_id => store.actions.basket.removeFromBasket(_id), [store]), - // Закрытие любой модалки closeModal: useCallback(() => store.actions.modals.close(), [store]), }; diff --git a/src/app/index.js b/src/app/index.js index 418efc0cd..3f8f3ef14 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -1,9 +1,13 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; +import { memo, useEffect } from 'react'; import { Routes, Route } from 'react-router-dom'; import useSelector from '../hooks/use-selector'; +import ProtectedRoute from './protected-route' import Main from './main'; import Basket from './basket'; import Article from './article'; +import Login from './login'; +import Profile from './profile'; +import useStore from '../hooks/use-store'; /** * Приложение @@ -11,10 +15,25 @@ import Article from './article'; */ function App() { const activeModal = useSelector(state => state.modals.name); + const store = useStore(); + + // Инициализация авторизации при загрузке + useEffect(() => { + const token = localStorage.getItem('token'); + if (token && !store.state.login?.user) { + store.actions.login.autoLogin(); + } + }, [store]); return ( <> + } /> + + + + } /> } /> } /> @@ -24,4 +43,4 @@ function App() { ); } -export default App; +export default memo(App); diff --git a/src/app/login/index.js b/src/app/login/index.js new file mode 100644 index 000000000..f32f226a6 --- /dev/null +++ b/src/app/login/index.js @@ -0,0 +1,70 @@ +import { memo, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useStore from '../../hooks/use-store'; +import useTranslate from '../../hooks/use-translate'; +import useSelector from '../../hooks/use-selector'; +import PageLayout from '../../components/page-layout'; +import Head from '../../components/head'; +import LocaleSelect from '../../containers/locale-select'; +import AuthContainer from '../../containers/auth'; +import Navigation from '../../containers/navigation'; +import Form from '../../components/login-form'; + +/** + * Страница авторизации пользователя + */ +function Login() { + const store = useStore(); + const { t } = useTranslate(); + const navigate = useNavigate(); + const [login, setLogin] = useState(''); + const [password, setPassword] = useState(''); + const { issues = null } = useSelector(state => state.login || {}); + + const handleLogin = useCallback(async (credentials) => { + try { + const success = await store.actions.login.login(credentials); + if (success) { + navigate('/'); + } + } catch (e) { + console.error('Ошибка:', e); + } + }, [store, navigate]); + + const handleSubmit = useCallback((e) => { + e.preventDefault(); + handleLogin({ + login: login.trim(), + password: password.trim() + }); + }, [login, password, handleLogin]); + + return ( + <> + + + + + + +
+ + + ); +} + +export default memo(Login); \ No newline at end of file diff --git a/src/app/main/index.js b/src/app/main/index.js index 332bd2d50..efae42644 100644 --- a/src/app/main/index.js +++ b/src/app/main/index.js @@ -1,6 +1,6 @@ -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import useStore from '../../hooks/use-store'; -import useTranslate from '../../hooks/use-translate'; +import useDynamicTitle from '../../hooks/use-title'; import useInit from '../../hooks/use-init'; import Navigation from '../../containers/navigation'; import PageLayout from '../../components/page-layout'; @@ -8,27 +8,29 @@ import Head from '../../components/head'; import CatalogFilter from '../../containers/catalog-filter'; import CatalogList from '../../containers/catalog-list'; import LocaleSelect from '../../containers/locale-select'; +import AuthContainer from '../../containers/auth'; /** * Главная страница - первичная загрузка каталога */ function Main() { const store = useStore(); + const { getTitle } = useDynamicTitle(); - useInit( - () => { - store.actions.catalog.initParams(); - }, - [], - true, - ); + useInit(() => { + store.actions.catalog.initParams(); + }, [], true); + + useEffect(() => { + document.title = getTitle(); + }, [getTitle]); - const { t } = useTranslate(); return ( <> - - + + + diff --git a/src/app/profile/index.js b/src/app/profile/index.js new file mode 100644 index 000000000..ccb2ca61b --- /dev/null +++ b/src/app/profile/index.js @@ -0,0 +1,40 @@ +import { memo } from 'react'; +import useTranslate from '../../hooks/use-translate'; +import useSelector from '../../hooks/use-selector'; +import PageLayout from '../../components/page-layout'; +import Head from '../../components/head'; +import LocaleSelect from '../../containers/locale-select'; +import AuthContainer from '../../containers/auth'; +import User from '../../components/user-info'; +import Navigation from '../../containers/navigation'; + +/** + * Страница профиля пользователя + */ +function Profile() { + const { t } = useTranslate(); + const { user } = useSelector(state => state.login || {}); + + return ( + <> + + + + + + + + + + ); +} + +export default memo(Profile); \ No newline at end of file diff --git a/src/app/protected-route/index.js b/src/app/protected-route/index.js new file mode 100644 index 000000000..5b200adac --- /dev/null +++ b/src/app/protected-route/index.js @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import useStore from '../../hooks/use-store'; +import useSelector from '../../hooks/use-selector'; +import { useNavigate } from 'react-router-dom'; + +/** + * Маршрут, защищающий данные от неавторизованных пользователей + */ +function ProtectedRoute({ children }) { + const store = useStore(); + const navigate = useNavigate(); + const { token, user } = useSelector(state => state.login || {}); + + useEffect(() => { + if (!token) { + navigate('/'); + } else if (!user) { + store.actions.login.autoLogin(); + } + }, [token, user, store, navigate]); + + return token ? children : null; +} + +export default ProtectedRoute; \ No newline at end of file diff --git a/src/components/article-card/index.js b/src/components/article-card/index.js index 1519e31f4..3eea1a805 100644 --- a/src/components/article-card/index.js +++ b/src/components/article-card/index.js @@ -13,22 +13,22 @@ function ArticleCard(props) {
{article.description}
-
Страна производитель:
+
{t('article.country')}
{article.madeIn?.title} ({article.madeIn?.code})
-
Категория:
+
{t('article.category')}
{article.category?.title}
-
Год выпуска:
+
{t('article.edition')}
{article.edition}
-
Цена:
+
{t('article.price')}
{numberFormat(article.price)} ₽
@@ -27,16 +24,9 @@ function BasketTool({ sum, amount, onOpen, t }) { BasketTool.propTypes = { onOpen: PropTypes.func.isRequired, - sum: PropTypes.number, - amount: PropTypes.number, - t: PropTypes.func, -}; - -BasketTool.defaultProps = { - onOpen: () => {}, - sum: 0, - amount: 0, - t: text => text, + sum: PropTypes.number.isRequired, + amount: PropTypes.number.isRequired, + t: PropTypes.func.isRequired, }; export default memo(BasketTool); diff --git a/src/components/basket-total/index.js b/src/components/basket-total/index.js index 5808df6a1..88b2da3c3 100644 --- a/src/components/basket-total/index.js +++ b/src/components/basket-total/index.js @@ -9,20 +9,15 @@ function BasketTotal({ sum, t }) { return (
{t('basket.total')} - {numberFormat(sum)} ₽ + {numberFormat(sum, undefined, { maximumFractionDigits: 0 })} ₽
); } BasketTotal.propTypes = { - sum: PropTypes.number, - t: PropTypes.func, -}; - -BasketTotal.defaultProps = { - sum: 0, - t: text => text, + sum: PropTypes.number.isRequired, + t: PropTypes.func.isRequired, }; export default memo(BasketTotal); diff --git a/src/components/button/index.js b/src/components/button/index.js index 900bbe6cf..210cecdf8 100644 --- a/src/components/button/index.js +++ b/src/components/button/index.js @@ -16,8 +16,8 @@ function Button({ onClick = () => {}, title, style, type = 'button' }) { } Button.propTypes = { - onClick: PropTypes.func, - title: PropTypes.string, + onClick: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, style: PropTypes.oneOf(['text', 'primary', 'delete', 'outline']), type: PropTypes.oneOf(['button', 'submit']), }; diff --git a/src/components/button/style.css b/src/components/button/style.css index 0efc8a896..4ea8ebb4d 100644 --- a/src/components/button/style.css +++ b/src/components/button/style.css @@ -34,8 +34,9 @@ .Button_style_text { padding: 0; border: none; + color: var(--primary); &:hover { - color: var(--primary); + color: var(--main-text); } -} +} \ No newline at end of file diff --git a/src/components/controls/index.js b/src/components/controls/index.js index 5f1b95acd..1d8e0fa88 100644 --- a/src/components/controls/index.js +++ b/src/components/controls/index.js @@ -11,11 +11,7 @@ function Controls({ onAdd }) { } Controls.propTypes = { - onAdd: PropTypes.func, -}; - -Controls.defaultProps = { - onAdd: () => {}, + onAdd: PropTypes.func.isRequired, }; export default memo(Controls); diff --git a/src/components/head/index.js b/src/components/head/index.js index d5fdd9e7c..d6cb5dbbd 100644 --- a/src/components/head/index.js +++ b/src/components/head/index.js @@ -14,8 +14,7 @@ function Head({ title, children }) { } Head.propTypes = { - title: PropTypes.node, - children: PropTypes.node, + title: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, }; - export default memo(Head); diff --git a/src/components/input/index.js b/src/components/input/index.js index e54d1c2fe..19be5439e 100644 --- a/src/components/input/index.js +++ b/src/components/input/index.js @@ -6,7 +6,6 @@ import debounce from 'lodash.debounce'; import './style.css'; function Input(props) { - // Внутренний стейт для быстрого отображения ввода const [value, setValue] = useState(props.value); const onChangeDebounce = useCallback( @@ -14,13 +13,11 @@ function Input(props) { [props.onChange, props.name], ); - // Обработчик изменений в поле const onChange = event => { setValue(event.target.value); onChangeDebounce(event.target.value); }; - // Обновление стейта, если передан новый value useLayoutEffect(() => setValue(props.value), [props.value]); const cn = bem('Input'); @@ -36,18 +33,12 @@ function Input(props) { } Input.propTypes = { - value: PropTypes.string, + value: PropTypes.string.isRequired, name: PropTypes.string, type: PropTypes.string, placeholder: PropTypes.string, - onChange: PropTypes.func, + onChange: PropTypes.func.isRequired, theme: PropTypes.string, }; -Input.defaultProps = { - onChange: () => {}, - type: 'text', - theme: '', -}; - export default memo(Input); diff --git a/src/components/item-basket/index.js b/src/components/item-basket/index.js index 5acd941f5..3f1024dad 100644 --- a/src/components/item-basket/index.js +++ b/src/components/item-basket/index.js @@ -1,8 +1,7 @@ -import { memo, useCallback } from 'react'; -import propTypes from 'prop-types'; +import { memo } from 'react'; +import PropTypes from 'prop-types'; import { numberFormat } from '../../utils'; import { cn as bem } from '@bem-react/classname'; -import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import Button from '../button'; import './style.css'; @@ -31,7 +30,7 @@ function ItemBasket(props) { {numberFormat(props.item.amount || 0)} {props.labelUnit}
- {numberFormat(props.item.price)} {props.labelCurr} + {numberFormat(props.item.price, undefined, { maximumFractionDigits: 0 })} ₽
- {numberFormat(props.item.price)} {props.labelCurr} + {numberFormat(props.item.price, undefined, { maximumFractionDigits: 0 })} ₽
@@ -31,20 +31,14 @@ function Item(props) { Item.propTypes = { item: PropTypes.shape({ - _id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - title: PropTypes.string, - price: PropTypes.number, + _id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + title: PropTypes.string.isRequired, + price: PropTypes.number.isRequired, }).isRequired, - link: PropTypes.string, - onAdd: PropTypes.func, + link: PropTypes.string.isRequired, + onAdd: PropTypes.func.isRequired, labelCurr: PropTypes.string, labelAdd: PropTypes.string, }; -Item.defaultProps = { - onAdd: () => {}, - labelCurr: '₽', - labelAdd: 'Добавить', -}; - export default memo(Item); diff --git a/src/components/list/index.js b/src/components/list/index.js index 6c8b485ef..b2fadfb18 100644 --- a/src/components/list/index.js +++ b/src/components/list/index.js @@ -1,6 +1,5 @@ import { memo } from 'react'; import PropTypes from 'prop-types'; -import Item from '../item'; import './style.css'; function List({ list, renderItem }) { @@ -18,14 +17,11 @@ function List({ list, renderItem }) { List.propTypes = { list: PropTypes.arrayOf( PropTypes.shape({ - _id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + _id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + description: PropTypes.string, }), ).isRequired, - renderItem: PropTypes.func, -}; - -List.defaultProps = { - renderItem: item => {}, + renderItem: PropTypes.func.isRequired, }; export default memo(List); diff --git a/src/components/login-form/index.js b/src/components/login-form/index.js new file mode 100644 index 000000000..9c37d3b5e --- /dev/null +++ b/src/components/login-form/index.js @@ -0,0 +1,49 @@ +import { memo } from 'react'; +import PropTypes from 'prop-types'; +import Button from '../button'; +import './style.css'; + + +function Form(props) { + return ( +
+

{props.title}

+ +
+ + props.onLoginChange(e.target.value)} + placeholder={props.placeholderLog} + required + /> +
+
+ + props.onPasswordChange(e.target.value)} + placeholder={props.placeholderPsw} + required + /> +
+
{props.issues}
+
+ ); +} + +Form.propTypes = { + onSubmit: PropTypes.func.isRequired, + labelLogin: PropTypes.string.isRequired, + issues: PropTypes.string, + loginValue: PropTypes.string.isRequired, + passwordValue: PropTypes.string.isRequired, + onLoginChange: PropTypes.func.isRequired, + onPasswordChange: PropTypes.func.isRequired +}; + +export default memo(Form); \ No newline at end of file diff --git a/src/components/login-form/style.css b/src/components/login-form/style.css new file mode 100644 index 000000000..77a9720c8 --- /dev/null +++ b/src/components/login-form/style.css @@ -0,0 +1,50 @@ +.Form { + margin-top: 24px; + color: var(--main-text); + font-weight: 400; +} + +.Form h1 { + font-family: var(--second-font-family); + font-weight: 700; + font-size: 32px; + line-height: 40px; +} + +.Form form { + margin: 24px 0; + width: 233px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.field label { + font-family: var(--font-family); + font-size: 14px; + line-height: 130%; +} + +.field input { + padding: 8px 12px; + font-family: var(--font-family); + font-size: 14px; + line-height: 130%; + border: 1px solid var(--filter-border); + border-radius: 4px; + outline: none; +} + +.issues { + font-family: var(--font-family); + font-size: 12px; + line-height: 100%; + color: var(--delete); +} \ No newline at end of file diff --git a/src/components/menu/index.js b/src/components/menu/index.js index e52963833..639631527 100644 --- a/src/components/menu/index.js +++ b/src/components/menu/index.js @@ -22,17 +22,12 @@ function Menu({ items, onNavigate }) { Menu.propTypes = { items: PropTypes.arrayOf( PropTypes.shape({ - key: PropTypes.number, - link: PropTypes.string, - title: PropTypes.string, + key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + link: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, }), - ), - onNavigate: PropTypes.func, -}; - -Menu.defaultProps = { - items: [], - onNavigate: () => {}, + ).isRequired, + onNavigate: PropTypes.func.isRequired, }; export default memo(Menu); diff --git a/src/components/modal-layout/index.js b/src/components/modal-layout/index.js index 6e2d3a046..f74dddd66 100644 --- a/src/components/modal-layout/index.js +++ b/src/components/modal-layout/index.js @@ -40,14 +40,9 @@ function ModalLayout(props) { } ModalLayout.propTypes = { - title: PropTypes.string, - onClose: PropTypes.func, - children: PropTypes.node, -}; - -ModalLayout.defaultProps = { - title: 'Модалка', - onClose: () => {}, + title: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, }; export default memo(ModalLayout); diff --git a/src/components/page-layout/index.js b/src/components/page-layout/index.js index d4bda2f84..2ff7bbe51 100644 --- a/src/components/page-layout/index.js +++ b/src/components/page-layout/index.js @@ -16,7 +16,9 @@ function PageLayout({ head, footer, children }) { } PageLayout.propTypes = { - children: PropTypes.node, + head: PropTypes.node, + footer: PropTypes.node, + children: PropTypes.node.isRequired, }; export default memo(PageLayout); diff --git a/src/components/pagination/index.js b/src/components/pagination/index.js index 98d10e364..2cf6c3830 100644 --- a/src/components/pagination/index.js +++ b/src/components/pagination/index.js @@ -3,33 +3,35 @@ import PropTypes from 'prop-types'; import { cn as bem } from '@bem-react/classname'; import './style.css'; -function Pagination(props) { +function Pagination({ + page = 1, + limit = 10, + count = 1000, + indent = 1, + onChange, + makeLink +}) { // Количество страниц - const length = Math.ceil(props.count / Math.max(props.limit, 1)); + const length = Math.ceil(count / Math.max(limit, 1)); - // Номера слева и справа относительно активного номера, которые остаются видимыми - let left = Math.max(props.page - props.indent, 1); - let right = Math.min(left + props.indent * 2, length); + // Номера слева и справа относительно активного номера + let left = Math.max(page - indent, 1); + let right = Math.min(left + indent * 2, length); // Корректировка когда страница в конце - left = Math.max(right - props.indent * 2, 1); + left = Math.max(right - indent * 2, 1); - // Массив номеров, чтобы удобней рендерить + // Массив номеров let items = []; - // Первая страница всегда нужна if (left > 1) items.push(1); - // Пропуск if (left > 2) items.push(null); - // Последовательность страниц - for (let page = left; page <= right; page++) items.push(page); - // Пропуск + for (let p = left; p <= right; p++) items.push(p); if (right < length - 1) items.push(null); - // Последняя страница if (right < length) items.push(length); const onClickHandler = number => e => { - if (props.onChange && number) { + if (onChange && number) { e.preventDefault(); - props.onChange(number); + onChange(number); } }; @@ -39,10 +41,17 @@ function Pagination(props) { {items.map((number, index) => (
  • - {number ? props.makeLink ? {number} : number : '...'} + {number ? ( + makeLink ? + {number} : + number + ) : '...'}
  • ))} @@ -58,11 +67,4 @@ Pagination.propTypes = { makeLink: PropTypes.func, }; -Pagination.defaultProps = { - page: 1, - limit: 10, - count: 1000, - indent: 1, -}; - -export default memo(Pagination); +export default memo(Pagination); \ No newline at end of file diff --git a/src/components/pagination/style.css b/src/components/pagination/style.css index 0c2c57e2a..d8cbe6afd 100644 --- a/src/components/pagination/style.css +++ b/src/components/pagination/style.css @@ -2,6 +2,7 @@ display: flex; list-style: none; justify-content: flex-end; + gap: 2px; padding-block: 24px; margin: 0; } diff --git a/src/components/select/index.js b/src/components/select/index.js index 87bb5b230..fc114f537 100644 --- a/src/components/select/index.js +++ b/src/components/select/index.js @@ -11,25 +11,27 @@ function Select(props) { }; return ( - +
    + +
    ); } Select.propTypes = { options: PropTypes.arrayOf( PropTypes.shape({ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - title: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + title: PropTypes.string.isRequired, }), ).isRequired, value: PropTypes.any, - onChange: PropTypes.func, + onChange: PropTypes.func.isRequired, size: PropTypes.oneOf(['small', 'medium']), text: PropTypes.bool, }; diff --git a/src/components/select/style.css b/src/components/select/style.css index 6f1bacb44..5817d08d2 100644 --- a/src/components/select/style.css +++ b/src/components/select/style.css @@ -13,11 +13,14 @@ outline: none; } -.Select option:checked, .Select option:hover { - background-color: var(--primary); + background: var(--primary); color: var(--second-text); } +.Select option:checked { + background: none; + color: var(--primary); +} .Select_size_small { min-width: 130px; diff --git a/src/components/side-layout/index.js b/src/components/side-layout/index.js index 5f648dc54..6fcd255a0 100644 --- a/src/components/side-layout/index.js +++ b/src/components/side-layout/index.js @@ -22,6 +22,4 @@ SideLayout.propTypes = { padding: PropTypes.oneOf(['small', 'medium']), }; -SideLayout.defaultProps = {}; - export default memo(SideLayout); diff --git a/src/components/spinner/index.js b/src/components/spinner/index.js index 5a453d2e8..0206ad0a5 100644 --- a/src/components/spinner/index.js +++ b/src/components/spinner/index.js @@ -15,6 +15,4 @@ Spinner.propTypes = { children: PropTypes.node, }; -Spinner.defaultProps = {}; - export default memo(Spinner); diff --git a/src/components/user-info/index.js b/src/components/user-info/index.js new file mode 100644 index 000000000..d6bedc6a3 --- /dev/null +++ b/src/components/user-info/index.js @@ -0,0 +1,38 @@ +import { memo } from 'react'; +import PropTypes from 'prop-types'; +import { cn as bem } from '@bem-react/classname'; +import './style.css'; + + +function User(props) { + const cn = bem('User'); + + return ( +
    +

    {props.title}

    +
    +
    +
    {props.userLabel}
    +
    {props.userName}
    +
    +
    +
    {props.userPhone}
    +
    {props.phone}
    +
    +
    +
    {props.userEmail}
    +
    {props.email}
    +
    +
    +
    + ); +} + +User.propTypes = { + userName: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + phone: PropTypes.string.isRequired, + title: PropTypes.string.isRequired +}; + +export default memo(User); \ No newline at end of file diff --git a/src/components/user-info/style.css b/src/components/user-info/style.css new file mode 100644 index 000000000..d1142ce29 --- /dev/null +++ b/src/components/user-info/style.css @@ -0,0 +1,32 @@ +.User { + display: flex; + flex-direction: column; + gap: 24px; + padding-block: 24px; +} + +.User h1 { + font-family: var(--second-font-family); + font-weight: 700; + font-size: 32px; + line-height: 40px; +} + +.User-prop-wrapper { + display: flex; + flex-direction: column; + gap: 12px; +} + +.User-prop { + display: flex; + gap: 8px; +} + +.User-label { + min-width: 73px; +} + +.User-value { + font-weight: bold; +} diff --git a/src/containers/auth/index.js b/src/containers/auth/index.js new file mode 100644 index 000000000..10f2664ac --- /dev/null +++ b/src/containers/auth/index.js @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; +import useStore from '../../hooks/use-store'; +import useSelector from '../../hooks/use-selector'; +import AuthView from '../../components/auth-view'; +import { useNavigate } from 'react-router-dom'; +import useTranslate from '../../hooks/use-translate'; + +function AuthContainer() { + const navigate = useNavigate(); + const { user, token } = useSelector(state => ({ + user: state.login?.user, + token: state.login?.token + })); + + const store = useStore(); + const { t } = useTranslate(); + + const handleLogout = useCallback(() => { + store.actions.login.logout(); + navigate('/'); + }, [store]); + + return ( + + ); +} + +export default AuthContainer; \ No newline at end of file diff --git a/src/containers/catalog-filter/index.js b/src/containers/catalog-filter/index.js index 2636a0988..6d33623a9 100644 --- a/src/containers/catalog-filter/index.js +++ b/src/containers/catalog-filter/index.js @@ -1,4 +1,5 @@ -import { memo, useCallback, useMemo } from 'react'; +// containers/catalog-filter +import { memo, useEffect, useCallback, useMemo } from 'react'; import useTranslate from '../../hooks/use-translate'; import useStore from '../../hooks/use-store'; import useSelector from '../../hooks/use-selector'; @@ -7,42 +8,68 @@ import Input from '../../components/input'; import SideLayout from '../../components/side-layout'; import Button from '../../components/button'; -/** - * Контейнер со всеми фильтрами каталога - */ function CatalogFilter() { const store = useStore(); + const { t } = useTranslate(); const select = useSelector(state => ({ sort: state.catalog.params.sort, query: state.catalog.params.query, + category: state.catalog.params.category, + categories: state.categories.items, })); + + useEffect(() => { + store.actions.categories.load(); + }, []); + + const getChildIds = useCallback((categoryId) => { + const findChildren = (id) => { + const children = select.categories.filter(cat => cat.parent?._id === id); + return [id, ...children.flatMap(child => findChildren(child._id))]; + }; + return categoryId ? findChildren(categoryId) : []; + }, [select.categories]); const callbacks = { - // Сортировка - onSort: useCallback(sort => store.actions.catalog.setParams({ sort }), [store]), - // Поиск - onSearch: useCallback(query => store.actions.catalog.setParams({ query, page: 1 }), [store]), - // Сброс + onSort: useCallback(sort => store.actions.catalog.setParams({ sort, category: store.getState().catalog.params.category }), [store]), + onSearch: useCallback(query => store.actions.catalog.setParams({ query, page: 1, category: store.getState().catalog.params.category }), [store]), onReset: useCallback(() => store.actions.catalog.resetParams(), [store]), + onCategory: useCallback(categoryId => { + const categoryIds = getChildIds(categoryId); + store.actions.catalog.setParams({ category: categoryIds, page: 1 }); + }, [store, getChildIds]), }; const options = { - sort: useMemo( - () => [ - { value: 'order', title: 'По порядку' }, - { value: 'title.ru', title: 'По именованию' }, - { value: '-price', title: 'Сначала дорогие' }, - { value: 'edition', title: 'Древние' }, - ], - [], - ), + sort: useMemo(() => [ + { value: 'order', title: 'По порядку' }, + { value: 'title.ru', title: 'По именованию' }, + { value: '-price', title: 'Сначала дорогие' }, + { value: 'edition', title: 'Древние' }, + ], []), + categories: useMemo(() => { + const allOption = { value: '', title: 'Все' }; + if (!select.categories.length) return [allOption]; + const buildTree = (parentId = null, level = 0) => + select.categories + .filter(cat => (parentId ? cat.parent?._id === parentId : !cat.parent)) + .flatMap(cat => [ + { value: cat._id, title: '- '.repeat(level) + cat.title }, + ...buildTree(cat._id, level + 1) + ]); + return [allOption, ...buildTree(null, 0)]; + }, [select.categories]), }; - const { t } = useTranslate(); - return ( + @@ -60,5 +87,4 @@ function CatalogFilter() { ); } - -export default memo(CatalogFilter); +export default memo(CatalogFilter); \ No newline at end of file diff --git a/src/containers/catalog-list/index.js b/src/containers/catalog-list/index.js index f949dc07f..3de2c8c09 100644 --- a/src/containers/catalog-list/index.js +++ b/src/containers/catalog-list/index.js @@ -21,6 +21,7 @@ function CatalogList() { query: state.catalog.params.query, count: state.catalog.count, waiting: state.catalog.waiting, + category: state.catalog.params.category, })); const callbacks = { @@ -31,14 +32,22 @@ function CatalogList() { // Генератор ссылки для пагинатора makePaginatorLink: useCallback( page => { - return `?${new URLSearchParams({ + const urlParams = new URLSearchParams({ page, limit: select.limit, sort: select.sort, query: select.query, - })}`; + }); + + if (select.category?.length > 0) { + select.category.forEach(cat => { + urlParams.append('search[category]', cat); + }); + } + + return `?${urlParams.toString()}`; }, - [select.limit, select.sort, select.query], + [select.limit, select.sort, select.query, select.category], ), }; diff --git a/src/containers/locale-select/index.js b/src/containers/locale-select/index.js index 3a16762fb..e9ae9921f 100644 --- a/src/containers/locale-select/index.js +++ b/src/containers/locale-select/index.js @@ -1,6 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; -import useStore from '../../hooks/use-store'; -import useSelector from '../../hooks/use-selector'; +import { memo, useMemo } from 'react'; import useTranslate from '../../hooks/use-translate'; import Select from '../../components/select'; diff --git a/src/hooks/use-title.js b/src/hooks/use-title.js new file mode 100644 index 000000000..7aea70a4d --- /dev/null +++ b/src/hooks/use-title.js @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import useSelector from './use-selector'; +import useTranslate from './use-translate'; + +export default function useDynamicTitle() { + const { t } = useTranslate(); + const select = useSelector(state => ({ + categoryId: state.catalog.params.category[0], + categories: state.categories.items, + lang: state.locale.lang + })); + + const getTitle = useCallback(() => { + const baseTitle = t('title'); + if (!select.categoryId) return baseTitle; + + const category = select.categories.find(c => c._id === select.categoryId); + return category ? `${baseTitle} / ${category.title}` : baseTitle; + }, [select.categoryId, select.categories, t]); + + return { getTitle }; +} \ No newline at end of file diff --git a/src/hooks/use-translate.js b/src/hooks/use-translate.js index a7c4f475e..c3cefa32a 100644 --- a/src/hooks/use-translate.js +++ b/src/hooks/use-translate.js @@ -1,4 +1,4 @@ -import { useCallback, useContext } from 'react'; +import { useContext } from 'react'; // import useStore from "../store/use-store"; // import useSelector from "../store/use-selector"; // import translate from "../i18n/translate"; diff --git a/src/i18n/context.js b/src/i18n/context.js index 3733d9bac..fe09d2062 100644 --- a/src/i18n/context.js +++ b/src/i18n/context.js @@ -1,4 +1,6 @@ -import { createContext, useMemo, useState } from 'react'; +import { createContext, useMemo } from 'react'; +import useSelector from '../hooks/use-selector'; +import useStore from '../hooks/use-store'; import translate from './translate'; /** @@ -12,19 +14,17 @@ export const I18nContext = createContext({}); * @return {JSX.Element} */ export function I18nProvider({ children }) { - const [lang, setLang] = useState('ru'); - - const i18n = useMemo( - () => ({ - // Код локали - lang, - // Функция для смены локали - setLang, - // Функция для локализации текстов с замыканием на код языка - t: (text, number) => translate(lang, text, number), - }), - [lang], - ); + const store = useStore(); + const lang = useSelector(state => state.locale.lang); // Берем язык из хранилища + + const i18n = useMemo(() => ({ + // Код локали + lang, + // Функция для смены локали + setLang: (lang) => store.actions.locale.setLang(lang), // Используем действие из хранилища + // Функция для локализации текстов с замыканием на код языка + t: (text, number) => translate(lang, text, number) + }), [lang, store]); return {children}; -} +} \ No newline at end of file diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index bf3707b10..df6b6c501 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -5,14 +5,31 @@ "basket.open": "Open", "basket.close": "Close", "basket.inBasket": "In cart", - "basket.empty": "empty", + "basket.empty": "Empty", "basket.total": "Total", "basket.unit": "pcs", "basket.delete": "Delete", + "article.country": "Country:", + "article.category": "Category:", + "article.edition": "Edition:", + "article.price": "Price:", "article.add": "Add", + "login.title": "Login", + "logout.title": "Logout", + "login.label": "Login", + "psw.label": "Password", + "login.btn": "Log in", + "profile.title": "Profile", + "input.search": "Search", + "placeholder.log": "Enter login", + "placeholder.psw": "Enter password", + "user.name": "Name:", + "user.phone": "Telephone:", + "user.email": "Email:", "filter.reset": "Reset", "basket.articles": { "one": "article", - "other": "articles" + "few": "articles", + "many": "articles" } } diff --git a/src/i18n/translations/ru.json b/src/i18n/translations/ru.json index eb84f293e..5ef818df5 100644 --- a/src/i18n/translations/ru.json +++ b/src/i18n/translations/ru.json @@ -5,16 +5,31 @@ "basket.open": "Перейти", "basket.close": "Закрыть", "basket.inBasket": "В корзине", - "basket.empty": "пусто", + "basket.empty": "Пусто", "basket.total": "Итого", "basket.unit": "шт", "basket.delete": "Удалить", + "article.country": "Страна производитель:", + "article.category": "Категория:", + "article.edition": "Год выпуска:", + "article.price": "Цена:", "article.add": "Добавить", + "login.title": "Вход", + "logout.title": "Выход", + "login.label": "Логин", + "psw.label": "Пароль", + "login.btn": "Войти", + "profile.title": "Профиль", + "input.search": "Поиск", + "placeholder.log": "Введите логин", + "placeholder.psw": "Введите пароль", + "user.name": "Имя:", + "user.phone": "Телефон:", + "user.email": "Email:", "filter.reset": "Сбросить", "basket.articles": { "one": "товар", "few": "товара", - "many": "товаров", - "other": "товара" + "many": "товаров" } } diff --git a/src/store/basket/index.js b/src/store/basket/index.js index f88c950a2..a2ebd8021 100644 --- a/src/store/basket/index.js +++ b/src/store/basket/index.js @@ -5,6 +5,23 @@ import StoreModule from '../module'; */ class BasketState extends StoreModule { initState() { + const savedBasket = localStorage.getItem('basket'); + if (savedBasket) { + try { + const parsed = JSON.parse(savedBasket); + // Проверяем структуру загруженных данных + if ( + Array.isArray(parsed.list) && + typeof parsed.sum === 'number' && + typeof parsed.amount === 'number' + ) { + return parsed; + } + } catch (e) { + console.error('Ошибка при загрузке корзины из LocalStorage:', e); + } + } + // Возвращаем начальное состояние, если сохранённых данных нет return { list: [], sum: 0, @@ -18,12 +35,11 @@ class BasketState extends StoreModule { */ async addToBasket(_id) { let sum = 0; - // Ищем товар в корзине, чтобы увеличить его количество let exist = false; const list = this.getState().list.map(item => { let result = item; if (item._id === _id) { - exist = true; // Запомним, что был найден в корзине + exist = true; result = { ...item, amount: item.amount + 1 }; } sum += result.price * result.amount; @@ -31,13 +47,10 @@ class BasketState extends StoreModule { }); if (!exist) { - // Поиск товара в каталоге, чтобы его добавить в корзину. const response = await fetch(`/api/v1/articles/${_id}`); const json = await response.json(); const item = json.result; - - list.push({ ...item, amount: 1 }); // list уже новый, в него можно пушить. - // Добавляем к сумме. + list.push({ ...item, amount: 1 }); sum += item.price; } @@ -50,6 +63,7 @@ class BasketState extends StoreModule { }, 'Добавление в корзину', ); + localStorage.setItem('basket', JSON.stringify(this.getState())); } /** @@ -73,7 +87,8 @@ class BasketState extends StoreModule { }, 'Удаление из корзины', ); + localStorage.setItem('basket', JSON.stringify(this.getState())); } } -export default BasketState; +export default BasketState; \ No newline at end of file diff --git a/src/store/catalog/index.js b/src/store/catalog/index.js index 3fa09a1d9..4eab2db32 100644 --- a/src/store/catalog/index.js +++ b/src/store/catalog/index.js @@ -7,13 +7,14 @@ class CatalogState extends StoreModule { /** * Начальное состояние * @return {Object} - */ + */ initState() { return { list: [], params: { page: 1, limit: 10, + category: [], sort: 'order', query: '', }, @@ -32,71 +33,67 @@ class CatalogState extends StoreModule { const urlParams = new URLSearchParams(window.location.search); let validParams = {}; if (urlParams.has('page')) validParams.page = Number(urlParams.get('page')) || 1; - if (urlParams.has('limit')) - validParams.limit = Math.min(Number(urlParams.get('limit')) || 10, 50); + if (urlParams.has('limit')) validParams.limit = Math.min(Number(urlParams.get('limit')) || 10, 50); if (urlParams.has('sort')) validParams.sort = urlParams.get('sort'); + if (urlParams.has('search[category]')) { + validParams.category = urlParams.getAll('search[category]'); + } if (urlParams.has('query')) validParams.query = urlParams.get('query'); await this.setParams({ ...this.initState().params, ...validParams, ...newParams }, true); } /** - * Сброс параметров к начальным - * @param [newParams] {Object} Новые параметры - * @return {Promise} + * Сброс параметров. */ async resetParams(newParams = {}) { - // Итоговые параметры из начальных, из URL и из переданных явно const params = { ...this.initState().params, ...newParams }; - // Установка параметров и загрузка данных await this.setParams(params); } /** - * Установка параметров и загрузка списка товаров - * @param [newParams] {Object} Новые параметры - * @param [replaceHistory] {Boolean} Заменить адрес (true) или новая запись в истории браузера (false) - * @returns {Promise} + * Установка параметров с сохранением в URL. */ async setParams(newParams = {}, replaceHistory = false) { - const params = { ...this.getState().params, ...newParams }; + const prevCategory = this.getState().params.category; + + const params = { ...this.getState().params, ...newParams, category: newParams.category || prevCategory }; + console.log('Setting params:', params); - // Установка новых параметров и признака загрузки this.setState( - { - ...this.getState(), - params, - waiting: true, - }, - 'Установлены параметры каталога', + { ...this.getState(), params, waiting: true }, + 'Установлены параметры каталога' ); - // Сохранить параметры в адрес страницы - let urlSearch = new URLSearchParams(params).toString(); - const url = window.location.pathname + '?' + urlSearch + window.location.hash; - if (replaceHistory) { - window.history.replaceState({}, '', url); - } else { - window.history.pushState({}, '', url); - } + const urlSearchParams = new URLSearchParams(); + urlSearchParams.set('page', params.page); + urlSearchParams.set('limit', params.limit); + urlSearchParams.set('sort', params.sort); + if (params.query) { + urlSearchParams.set('query', params.query); + } + if (Array.isArray(params.category)) { + params.category.forEach(cat => urlSearchParams.append('search[category]', cat)); + } + + const url = window.location.pathname + '?' + urlSearchParams.toString() + window.location.hash; + replaceHistory ? window.history.replaceState({}, '', url) : window.history.pushState({}, '', url); + const apiParams = { limit: params.limit, skip: (params.page - 1) * params.limit, fields: 'items(*),count', + ...(params.category.length && { 'search[category]': params.category }), sort: params.sort, 'search[query]': params.query, }; - + const response = await fetch(`/api/v1/articles?${new URLSearchParams(apiParams)}`); const json = await response.json(); + this.setState( - { - ...this.getState(), - list: json.result.items, - count: json.result.count, - waiting: false, - }, - 'Загружен список товаров из АПИ', + { ...this.getState(), list: json.result.items, count: json.result.count, waiting: false }, + 'Загружен список товаров из АПИ' ); } } diff --git a/src/store/categories/index.js b/src/store/categories/index.js new file mode 100644 index 000000000..935d65a5e --- /dev/null +++ b/src/store/categories/index.js @@ -0,0 +1,25 @@ +import StoreModule from "../module"; + +class CategoriesState extends StoreModule { + initState() { + return { + items: [], + }; + } + async load() { + const response = await fetch( + "/api/v1/categories?fields=_id,title,parent(_id)&limit=*" + ); + const json = await response.json(); + + this.setState( + { + ...this.getState(), + items: json.result.items, + }, + "Загружен список товаров из АПИ" + ); + } +} + +export default CategoriesState; diff --git a/src/store/exports.js b/src/store/exports.js index a83ce5e41..7f68ae0a4 100644 --- a/src/store/exports.js +++ b/src/store/exports.js @@ -3,3 +3,5 @@ export { default as catalog } from './catalog'; export { default as modals } from './modals'; export { default as article } from './article'; export { default as locale } from './locale'; +export { default as login } from './login'; +export { default as categories } from './categories'; \ No newline at end of file diff --git a/src/store/locale/index.js b/src/store/locale/index.js index 89edd8759..997150534 100644 --- a/src/store/locale/index.js +++ b/src/store/locale/index.js @@ -3,7 +3,7 @@ import StoreModule from '../module'; class LocaleState extends StoreModule { initState() { return { - lang: 'ru', + lang: localStorage.getItem('lang') || 'ru', }; } @@ -12,6 +12,7 @@ class LocaleState extends StoreModule { * @param lang */ setLang(lang) { + localStorage.setItem('lang', lang); this.setState({ lang }, 'Установлена локаль'); } } diff --git a/src/store/login/index.js b/src/store/login/index.js new file mode 100644 index 000000000..e67a7c759 --- /dev/null +++ b/src/store/login/index.js @@ -0,0 +1,132 @@ +import StoreModule from '../module'; + +/** + * Страница авторизации + */ +class LoginState extends StoreModule { + initState() { + return { + user: null, + token: localStorage.getItem('token') || sessionStorage.getItem('token') || '', + issues: null + }; + } + + /** + * Вход пользователя + * @param credentials Реквезиты для входа + */ + async login(credentials) { + try { + const response = await fetch('/api/v1/users/sign', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + login: credentials.login, + password: credentials.password + }) + }); + + const data = await response.json(); + + if (!response.ok || !data.result?.token) { + throw new Error('Неверный логин или пароль'); + } + + localStorage.setItem('token', data.result.token); + + this.setState({ + ...this.state, + token: data.result.token, + user: data.result.user, + issues: null + }); + + return true; + } catch (e) { + this.setState({...this.state, issues: e.message}); + return false; + } + } + + async autoLogin() { + // Получаем токен из localStorage + const token = localStorage.getItem('token') || sessionStorage.getItem('token'); + + if (!token) return; + + try { + // Запрашиваем данные пользователя с токеном + const response = await fetch('/api/v1/users/self?fields=*', { + headers: { + 'X-Token': token, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const userData = await response.json(); + + // Обновляем состояние + this.setState({ + ...this.state, + user: userData.result, // Предполагаем, что данные в поле result + token: token, + issues: null + }); + + } catch (e) { + console.error('Ошибка автоматической авторизации:', e); + this.logout(); + } + } + + async fetchUserData() { + try { + const token = this.state.token; + if (!token) throw new Error('Токен отсутствует'); + + const response = await fetch('/api/v1/users/self?fields=*', { + headers: { + 'X-Token': token, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const userData = await response.json(); + + this.setState({ + ...this.state, + user: userData + }); + + return userData; + + } catch (e) { + console.error('Ошибка загрузки профиля:', e); + this.logout(); + throw e; + } + } + + logout() { + localStorage.removeItem('token'); + sessionStorage.removeItem('token'); + + this.setState({ + ...this.state, + user: null, + token: '', + issues: null + }, 'Выход из системы'); + } +} + +export default LoginState;