diff --git a/src/app/article/index.js b/src/app/article/index.js index 54f037b64..cc9fadab7 100644 --- a/src/app/article/index.js +++ b/src/app/article/index.js @@ -1,4 +1,4 @@ -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import useStore from '../../hooks/use-store'; import useTranslate from '../../hooks/use-translate'; @@ -14,19 +14,21 @@ import { useDispatch, useSelector } from 'react-redux'; import shallowequal from 'shallowequal'; import articleActions from '../../store-redux/article/actions'; import HeadLayout from '../../components/head-layout'; +import CommentsList from '../../components/comments-list' function Article() { const store = useStore(); const dispatch = useDispatch(); // Параметры из пути /articles/:id - + const { t, lang } = useTranslate(); + const params = useParams(); useInit(() => { //store.actions.article.load(params.id); dispatch(articleActions.load(params.id)); - }, [params.id]); + }, [params.id, lang]); const select = useSelector( state => ({ @@ -36,7 +38,6 @@ function Article() { shallowequal, ); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект - const { t } = useTranslate(); const callbacks = { // Добавление в корзину @@ -56,6 +57,7 @@ function Article() { + ); diff --git a/src/app/main/index.js b/src/app/main/index.js index d9fb1c6c2..054be0a27 100644 --- a/src/app/main/index.js +++ b/src/app/main/index.js @@ -13,16 +13,16 @@ import HeadLayout from '../../components/head-layout'; function Main() { const store = useStore(); + const { t, lang } = useTranslate(); useInit( async () => { await Promise.all([store.actions.catalog.initParams(), store.actions.categories.load()]); }, - [], + [lang], true, ); - const { t } = useTranslate(); return ( <> diff --git a/src/components/article-card/index.js b/src/components/article-card/index.js index d636d9f88..f1939c78d 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.madeIn')}:
{article.madeIn?.title} ({article.madeIn?.code})
-
Категория:
+
{t('article.category')}:
{article.category?.title}
-
Год выпуска:
+
{t('article.edition')}:
{article.edition}
-
Цена:
+
{t('article.price')}:
{numberFormat(article.price)} ₽
} + + + + ); +} + +CommentForm.propTypes = { + onSubmit: PropTypes.func, + onCancel: PropTypes.func, + onChange: PropTypes.func, + title: PropTypes.string, + submitTitle: PropTypes.string, + cancelTitle: PropTypes.string, + value: PropTypes.string +}; + +export default memo(CommentForm); diff --git a/src/components/comment-form/style.css b/src/components/comment-form/style.css new file mode 100644 index 000000000..812b87e85 --- /dev/null +++ b/src/components/comment-form/style.css @@ -0,0 +1,29 @@ +.CommentForm { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.CommentForm-title { + font-size: 16px; +} + +.CommentForm-text { + padding: 8px 12px; + font-size: 14px; + width: 100%; + height: 88px; + resize: none; + border-radius: 4px; + border: 1px solid var(--filter-border); + + &:focus{ + outline: none; + } +} + +.CommentForm-actions { + display: flex; + gap: 16px; +} diff --git a/src/components/comment/index.js b/src/components/comment/index.js new file mode 100644 index 000000000..e7c8f7e54 --- /dev/null +++ b/src/components/comment/index.js @@ -0,0 +1,40 @@ +import { memo } from 'react'; +import PropTypes from 'prop-types'; +import { cn as bem } from '@bem-react/classname'; +import './style.css'; +import dateFormat from '../../utils/date-format' + +function Comment({ comment, onAnswer = () => {}, t}) { + + const cn = bem('Comment'); + return ( +
+
+

{comment.author}

+ +
+
+

{comment.text}

+
+ +
+ ); +} + +Comment.propTypes = { + comment: PropTypes.shape({ + id: PropTypes.string, + text: PropTypes.string, + dateCreate: PropTypes.string, + author: PropTypes.string, + }), + t: PropTypes.func, + onAnswer: PropTypes.func, +}; + +export default memo(Comment); diff --git a/src/components/comment/style.css b/src/components/comment/style.css new file mode 100644 index 000000000..29593f3a4 --- /dev/null +++ b/src/components/comment/style.css @@ -0,0 +1,39 @@ +.Comment { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + /* padding-block: 24px; */ + font-size: 12px; +} + +.Comment-header { + display: flex; + align-items: center; + gap: 12px; +} + +.Comment-title { + font-size: 12px; +} + +.Comment-date { + color: var(--date); +} + +.Comment-text { + font-size: 14px; + line-height: 143%; +} + +.Comment-text > p { + margin: 0; +} + +.Comment-action { + padding: 0; + font-family: var(--second-font-family); + font-weight: 700; + color: var(--primary); +} + diff --git a/src/components/comments-list/index.js b/src/components/comments-list/index.js new file mode 100644 index 000000000..e657f0f37 --- /dev/null +++ b/src/components/comments-list/index.js @@ -0,0 +1,128 @@ +import { memo, useCallback, useMemo, useState } from 'react'; +import { cn as bem } from '@bem-react/classname'; +import './style.css'; +import Comment from '../comment' +import useInit from '../../hooks/use-init'; +import { useParams } from 'react-router-dom'; +import { useDispatch, useSelector as useSelectorRedux } from 'react-redux'; +import listToTree from '../../utils/list-to-tree' +import treeToList from '../../utils/tree-to-list' +import commentsActions from '../../store-redux/comments/actions' +import shallowequal from 'shallowequal'; +import useSelector from '../../hooks/use-selector' +import LoginMessage from '../login-message' +import CommentForm from '../comment-form' +import useTranslate from '../../hooks/use-translate' + + +function CommentsList() { + const dispatch = useDispatch(); + // Параметры из пути /articles/:id + const params = useParams(); + const [newComment, setNewComment] = useState({text: '', parentId: params.id, parentType: 'article'}) + const [currentComment, setCurrentComment] = useState(params.id) + const { t } =useTranslate() + + useInit(() => { + dispatch(commentsActions.load(params.id)) + }, [params.id]); + + const select = useSelector(state => ({ + exist: state.session.exists + })) + + const selectRedux = useSelectorRedux( + state => ({ + waiting: state.comments.waiting, + comments: state.comments.data + }), + shallowequal, + ); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект + + const options = { + comments: useMemo( + () => [ + ...treeToList(listToTree(selectRedux.comments.items, '_id', '_type'), (comment, level) => ({ + id: comment._id, + level: level * 40, + text: comment.text, + dateCreate: comment.dateCreate, + author: comment.author?.profile.name, + parent: comment.parent?._type, + parentId: comment.parent?._id + })) + ], + [selectRedux.comments] + ), + } + + const callbacks = { + onSubmit: useCallback((e) => { + e.preventDefault() + dispatch(commentsActions.post(newComment)) + setNewComment({ text: '', parentId: params.id, parentType: 'article' }); + setCurrentComment(params.id) + dispatch(commentsActions.load(params.id)) + }, [newComment, params.id, dispatch]), + onChange: useCallback((value) => { + setNewComment(prev => ({...prev, text: value})) + }), + onCancel: useCallback(() => { + setNewComment({text: '', parentId: params.id, parentType: 'article'}) + setCurrentComment(params.id) + }), + onAnswer: useCallback((commentId) => { + setNewComment(prev => ({...prev, parentId: commentId, parentType: 'comment'})) + setCurrentComment(commentId) + }) + } + + const cn = bem('CommentsList'); + return ( +
+

{t('comments.title')} ({selectRedux.comments.count})

+ {} + {currentComment === params.id && (select.exist + ? + : + )} +
+ ); +} + +export default memo(CommentsList); diff --git a/src/components/comments-list/style.css b/src/components/comments-list/style.css new file mode 100644 index 000000000..67f6b4f2f --- /dev/null +++ b/src/components/comments-list/style.css @@ -0,0 +1,16 @@ +.CommentsList { + display: flex; + flex-direction: column; + gap: 24px; + padding-block: 26px; +} + +.CommentsList-list { + display: flex; + flex-direction: column; + gap: 16px; + + margin: 0; + padding: 0; + list-style: none; +} \ No newline at end of file diff --git a/src/components/login-message/index.js b/src/components/login-message/index.js new file mode 100644 index 000000000..2dab2d3c3 --- /dev/null +++ b/src/components/login-message/index.js @@ -0,0 +1,19 @@ +import { memo } from 'react'; +import { cn as bem } from '@bem-react/classname'; +import './style.css'; + +import { Link } from 'react-router-dom'; + + + +function LoginMessage() { + + const cn = bem('LoginMessage'); + return ( +
+ Войдите, чтобы иметь возможность комментировать +
+ ); +} + +export default memo(LoginMessage); diff --git a/src/components/login-message/style.css b/src/components/login-message/style.css new file mode 100644 index 000000000..c0a2f1823 --- /dev/null +++ b/src/components/login-message/style.css @@ -0,0 +1,3 @@ +.LoginMessage-link { + color: var(--primary); +} \ No newline at end of file diff --git a/src/global.css b/src/global.css index e81695d21..08c9d3507 100644 --- a/src/global.css +++ b/src/global.css @@ -13,6 +13,7 @@ --odd-item: #6B4ACB08; --close: #878787; --filter-border: #D3D3D3; + --date: #666666; --font-family: 'Golos Text'; --second-font-family: 'Montserrat Alternates'; @@ -43,7 +44,7 @@ button { background-color: transparent; } -h1, h2, h4 { +h1, h2, h3, h4 { margin: 0; } diff --git a/src/hooks/use-translate.js b/src/hooks/use-translate.js index bdcbf1071..3a708a14b 100644 --- a/src/hooks/use-translate.js +++ b/src/hooks/use-translate.js @@ -1,9 +1,34 @@ -import { useCallback, useContext } from 'react'; -import { I18nContext } from '../i18n/context'; +import { useState, useEffect, useMemo } from 'react'; +import useServices from './use-services' /** * Хук возвращает функцию для локализации текстов, код языка и функцию его смены */ export default function useTranslate() { - return useContext(I18nContext); + const i18n = useServices().i18n + const [lang, setLang] = useState(i18n.getLang()) + + const unsubscribe = useMemo(() => { + // Подписка. Возврат функции для отписки + return i18n.subscribe(newLang => { + setLang(newLang) + }) + }, []) + + useEffect(() => unsubscribe, [unsubscribe]) + + const i18nServ = useMemo( + () => ({ + // Код локали + lang, + // Функция для смены локали + setLang: (lang) => i18n.setLang(lang), + // Функция для локализации текстов с замыканием на код языка + t: (text, number) => i18n.translate(text, number), + }), + [lang], + ); + + + return i18nServ } diff --git a/src/i18n/context.js b/src/i18n/context.js deleted file mode 100644 index 3733d9bac..000000000 --- a/src/i18n/context.js +++ /dev/null @@ -1,30 +0,0 @@ -import { createContext, useMemo, useState } from 'react'; -import translate from './translate'; - -/** - * @type {React.Context<{}>} - */ -export const I18nContext = createContext({}); - -/** - * Обертка над провайдером контекста, чтобы управлять изменениями в контексте - * @param children - * @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], - ); - - return {children}; -} diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 000000000..03a94edbf --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,69 @@ +import * as translations from './translations'; + +class I18nService { + + constructor(services, config = {}) { + this.services = services + this.config = config + this.listeners = []; + this.lang = 'ru' + } + + /** + * Перевод фразы по словарю + * @param lang {String} Код языка + * @param text {String} Текст для перевода + * @param [plural] {Number} Число для плюрализации + * @returns {String} Переведенный текст + */ + translate(text, plural, lang = this.lang) { + let result = translations[lang] && text in translations[lang] ? translations[lang][text] : text; + + if (typeof plural !== 'undefined') { + const key = new Intl.PluralRules(lang).select(plural); + if (key in result) { + result = result[key]; + } + } + + return result; + } + + /** + * Получение текущего языка + * @returns {String} + */ + getLang() { + return this.lang + } + + /** + * Установка нового языка + * @param newLang {String} + * @returns {} + */ + setLang(newLang) { + this.lang = newLang + this.services.api.setHeader('X-Lang', newLang) + this.services.api.setHeader('Accept-Languages', newLang) + + for (const listener of this.listeners) { + listener(this.lang); + } + } + + /** + * Подписка слушателя на изменения состояния + * @param listener {Function} + * @returns {Function} Функция отписки + */ + subscribe(listener) { + this.listeners.push(listener); + // Возвращается функция для удаления добавленного слушателя + return () => { + this.listeners = this.listeners.filter(item => item !== listener); + }; + } +} + +export default I18nService \ No newline at end of file diff --git a/src/i18n/translate.js b/src/i18n/translate.js deleted file mode 100644 index c4c60db93..000000000 --- a/src/i18n/translate.js +++ /dev/null @@ -1,21 +0,0 @@ -import * as translations from './translations'; - -/** - * Перевод фразу по словарю - * @param lang {String} Код языка - * @param text {String} Текст для перевода - * @param [plural] {Number} Число для плюрализации - * @returns {String} Переведенный текст - */ -export default function translate(lang, text, plural) { - let result = translations[lang] && text in translations[lang] ? translations[lang][text] : text; - - if (typeof plural !== 'undefined') { - const key = new Intl.PluralRules(lang).select(plural); - if (key in result) { - result = result[key]; - } - } - - return result; -} diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 0ebbcf8f2..a420f8f20 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -22,5 +22,15 @@ "auth.login-placeholder": "Enter login", "auth.password-placeholder": "Enter password", "session.signIn": "Sign In", - "session.signOut": "Sign Out" + "session.signOut": "Sign Out", + "article.madeIn": "Made In", + "article.category": "Category", + "article.edition": "Edition", + "article.price": "Price", + "comments.title": "Comments", + "comments.formAnswer": "New answer", + "comments.formComment": "New comment", + "comments.submit": "Submit", + "comments.cancel": "Cancel", + "comments.answer": "Answer" } diff --git a/src/i18n/translations/ru.json b/src/i18n/translations/ru.json index a18fb845e..0aae432ce 100644 --- a/src/i18n/translations/ru.json +++ b/src/i18n/translations/ru.json @@ -24,5 +24,15 @@ "auth.login-placeholder": "Введите логин", "auth.password-placeholder": "Введите пароль", "session.signIn": "Вход", - "session.signOut": "Выход" + "session.signOut": "Выход", + "article.madeIn": "Страна производитель", + "article.category": "Категория", + "article.edition": "Год выпуска", + "article.price": "Цена", + "comments.title": "Комментарии", + "comments.formAnswer": "Новый ответ", + "comments.formComment": "Новый комментарий", + "comments.submit": "Отправить", + "comments.cancel": "Отмена", + "comments.answer": "Ответить" } diff --git a/src/index.js b/src/index.js index f49db385a..d540a3148 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,6 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import { ServicesContext } from './context'; -import { I18nProvider } from './i18n/context'; import App from './app'; import Services from './services'; import config from './config'; @@ -16,11 +15,9 @@ const root = createRoot(document.getElementById('root')); root.render( - - , ); diff --git a/src/services.js b/src/services.js index a32b14d57..e3b31464a 100644 --- a/src/services.js +++ b/src/services.js @@ -1,6 +1,7 @@ import APIService from './api'; import Store from './store'; import createStoreRedux from './store-redux'; +import I18nService from './i18n'; class Services { constructor(config) { @@ -38,6 +39,17 @@ class Services { } return this._redux; } + + /** + * Сервис I18nService + * @returns {I18nService} + */ + get i18n() { + if (!this._i18n) { + this._i18n = new I18nService(this, this.config.i18n) + } + return this._i18n + } } export default Services; diff --git a/src/store-redux/comments/actions.js b/src/store-redux/comments/actions.js new file mode 100644 index 000000000..c1f599058 --- /dev/null +++ b/src/store-redux/comments/actions.js @@ -0,0 +1,47 @@ +export default { + /** + * Загрузка списка комментариев для товара + * @param id + * @return {Function} + */ + load: id => { + return async (dispatch, getState, services) => { + // Сброс комментариев и установка признака ожидания загрузки + dispatch({ type: 'comments/load-start' }); + + try { + const res = await services.api.request({ + url: `/api/v1/comments?fields=items(_id,text,dateCreate,author(profile(name)),parent(_id,_type),isDeleted),count&limit=*&search[parent]=${id}`, + }); + // Комментарии загружены успешно + dispatch({ type: 'comments/load-success', payload: { data: res.data.result } }); + } catch (e) { + //Ошибка загрузки + dispatch({ type: 'comments/load-error' }); + } + }; + }, + + post: ({text, parentId, parentType}) => { + return async (dispatch, getState, services) => { + dispatch({ type: 'comments/create-start' }) + + try { + const res = await services.api.request({ + url: `/api/v1/comments`, + method: 'POST', + body: JSON.stringify({ + "_id": "", + "text": text, + 'parent': {"_id": parentId, "_type": parentType} + }) + }); + // Комментарий загружен успешно + dispatch({ type: 'comments/create-success' }); + } catch (e) { + //Ошибка загрузки + dispatch({ type: 'comments/create-error' }); + } + } + } +}; diff --git a/src/store-redux/comments/reducer.js b/src/store-redux/comments/reducer.js new file mode 100644 index 000000000..19b24b369 --- /dev/null +++ b/src/store-redux/comments/reducer.js @@ -0,0 +1,35 @@ +// Начальное состояние +export const initialState = { + data: { + count: 0, + items: [] + }, + waiting: false, // признак ожидания загрузки +}; + +// Обработчик действий +function reducer(state = initialState, action) { + switch (action.type) { + case 'comments/load-start': + return { ...state, data: {count: 0, + items: []}, waiting: true }; + + case 'comments/load-success': + return { ...state, data: {count: action.payload.data.count, items: action.payload.data.items}, waiting: false }; + + case 'comments/load-error': + return { ...state, data: {}, waiting: false }; //@todo текст ошибки сохранять? + + case 'comments/create-start': + return { ...state, waiting: true} + + case 'comments/create-success': + return { ...state, waiting: false} + + default: + // Нет изменений + return state; + } +} + +export default reducer; diff --git a/src/store-redux/exports.js b/src/store-redux/exports.js index 1a0a3d742..c7f1c588b 100644 --- a/src/store-redux/exports.js +++ b/src/store-redux/exports.js @@ -1,2 +1,3 @@ export { default as article } from './article/reducer'; export { default as modals } from './modals/reducer'; +export { default as comments } from './comments/reducer'; diff --git a/src/utils/date-format.js b/src/utils/date-format.js new file mode 100644 index 000000000..9beb39379 --- /dev/null +++ b/src/utils/date-format.js @@ -0,0 +1,16 @@ +/** + * Форматирование даты + * @param value {String} + * @param options {Object} + * @returns {String} + */ +export default function dateFormat(value, locale = 'ru-RU', options = { + day: "numeric", + month: "long", + year: "numeric", + hour: "numeric", + minute: "numeric", + }) { + const date = new Date(value) + return new Intl.DateTimeFormat(locale, options).format(date); +} \ No newline at end of file diff --git a/src/utils/list-to-tree/index.js b/src/utils/list-to-tree/index.js index fb4d20a66..9d7772607 100644 --- a/src/utils/list-to-tree/index.js +++ b/src/utils/list-to-tree/index.js @@ -4,7 +4,7 @@ * @param [key] {String} Свойство с первичным ключом * @returns {Array} Корневые узлы */ -export default function listToTree(list, key = '_id') { +export default function listToTree(list, key = '_id', type = '') { let trees = {}; let roots = {}; for (const item of list) { @@ -19,7 +19,7 @@ export default function listToTree(list, key = '_id') { } // Если элемент имеет родителя, то добавляем его в подчиненные родителя - if (item.parent?.[key]) { + if (item.parent?.[key] && item.parent?.[type] !== 'article') { // Если родителя ещё нет в индексе, то индекс создаётся, ведь _id родителя известен if (!trees[item.parent[key]]) { trees[item.parent[key]] = { children: [] };