From e10a2c4d9d188be58b038f0c6d595b2ffa08cfca Mon Sep 17 00:00:00 2001 From: Mikhail Morozov Date: Thu, 17 Apr 2025 18:33:37 +0300 Subject: [PATCH 1/5] feat: displaying a list of comments --- src/app/article/index.js | 4 +- src/components/comment/index.js | 35 ++++++++++++++ src/components/comment/style.css | 39 ++++++++++++++++ src/components/comments-list/index.js | 64 ++++++++++++++++++++++++++ src/components/comments-list/style.css | 16 +++++++ src/global.css | 3 +- src/store-redux/comments/actions.js | 24 ++++++++++ src/store-redux/comments/reducer.js | 29 ++++++++++++ src/store-redux/exports.js | 1 + src/utils/date-format.js | 16 +++++++ src/utils/list-to-tree/index.js | 4 +- 11 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 src/components/comment/index.js create mode 100644 src/components/comment/style.css create mode 100644 src/components/comments-list/index.js create mode 100644 src/components/comments-list/style.css create mode 100644 src/store-redux/comments/actions.js create mode 100644 src/store-redux/comments/reducer.js create mode 100644 src/utils/date-format.js diff --git a/src/app/article/index.js b/src/app/article/index.js index 54f037b64..59e4c0aff 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,6 +14,7 @@ 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(); @@ -56,6 +57,7 @@ function Article() { + ); diff --git a/src/components/comment/index.js b/src/components/comment/index.js new file mode 100644 index 000000000..138ac2717 --- /dev/null +++ b/src/components/comment/index.js @@ -0,0 +1,35 @@ +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 = () => {}}) { + + 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, + level: PropTypes.number + }), + t: 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..4f97e4828 --- /dev/null +++ b/src/components/comments-list/index.js @@ -0,0 +1,64 @@ +import { memo, useCallback, useMemo } 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'; + + +function CommentsList() { + const dispatch = useDispatch(); + // Параметры из пути /articles/:id + const params = useParams(); + + useInit(() => { + dispatch(commentsActions.load(params.id)) + }, [params.id]); + + 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 + })) + ], + [select.comments] + ) + } + + const cn = bem('CommentsList'); + return ( +
+

Комментарии ({selectRedux.comments.count})

+ {
    + {options.comments.map(comment => ( +
  • + +
  • + ) + )} +
} + +
+ ); +} + +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/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/store-redux/comments/actions.js b/src/store-redux/comments/actions.js new file mode 100644 index 000000000..8dbdbed41 --- /dev/null +++ b/src/store-redux/comments/actions.js @@ -0,0 +1,24 @@ +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' }); + } + }; + }, +}; diff --git a/src/store-redux/comments/reducer.js b/src/store-redux/comments/reducer.js new file mode 100644 index 000000000..8d8e17aca --- /dev/null +++ b/src/store-redux/comments/reducer.js @@ -0,0 +1,29 @@ +// Начальное состояние +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 текст ошибки сохранять? + + 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: [] }; From 336caeee8e49176d632f84fc9b88afa59a0a33f5 Mon Sep 17 00:00:00 2001 From: Mikhail Morozov Date: Thu, 17 Apr 2025 22:36:34 +0300 Subject: [PATCH 2/5] feat: Sending a new comment --- src/components/comment-form/index.js | 35 +++++++++++++ src/components/comment-form/style.css | 29 +++++++++++ src/components/comment/index.js | 4 +- src/components/comments-list/index.js | 68 +++++++++++++++++++++++--- src/components/login-message/index.js | 19 +++++++ src/components/login-message/style.css | 3 ++ src/store-redux/comments/actions.js | 27 +++++++++- src/store-redux/comments/reducer.js | 6 +++ 8 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 src/components/comment-form/index.js create mode 100644 src/components/comment-form/style.css create mode 100644 src/components/login-message/index.js create mode 100644 src/components/login-message/style.css diff --git a/src/components/comment-form/index.js b/src/components/comment-form/index.js new file mode 100644 index 000000000..627495d22 --- /dev/null +++ b/src/components/comment-form/index.js @@ -0,0 +1,35 @@ +import { memo } from 'react'; +import PropTypes from 'prop-types'; +import { cn as bem } from '@bem-react/classname'; +import Button from '../button'; +import './style.css'; + +function CommentForm({ title, onSubmit, submitTitle, onCancel, onChange, value }) { + const cn = bem('CommentForm'); + + return ( +
+

Новый {title}

+ +
+ +
+
+ ); +} + +CommentForm.propTypes = { + onSubmit: PropTypes.func, + onCancel: PropTypes.func, + onChange: PropTypes.func, + title: PropTypes.string, + submitTitle: 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 index 138ac2717..c815351d4 100644 --- a/src/components/comment/index.js +++ b/src/components/comment/index.js @@ -8,7 +8,7 @@ function Comment({ comment, onAnswer = () => {}}) { const cn = bem('Comment'); return ( -
+

{comment.author}

@@ -27,9 +27,9 @@ Comment.propTypes = { text: PropTypes.string, dateCreate: PropTypes.string, author: PropTypes.string, - level: PropTypes.number }), t: PropTypes.func, + onAnswer: PropTypes.func, }; export default memo(Comment); diff --git a/src/components/comments-list/index.js b/src/components/comments-list/index.js index 4f97e4828..6c47da08a 100644 --- a/src/components/comments-list/index.js +++ b/src/components/comments-list/index.js @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { cn as bem } from '@bem-react/classname'; import './style.css'; import Comment from '../comment' @@ -9,17 +9,26 @@ 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' 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) useInit(() => { dispatch(commentsActions.load(params.id)) }, [params.id]); + const select = useSelector(state => ({ + exist: state.session.exists + })) + const selectRedux = useSelectorRedux( state => ({ waiting: state.comments.waiting, @@ -37,11 +46,30 @@ function CommentsList() { text: comment.text, dateCreate: comment.dateCreate, author: comment.author?.profile.name, - parent: comment.parent?._type + parent: comment.parent?._type, + parentId: comment.parent?._id })) ], - [select.comments] - ) + [selectRedux.comments] + ), + } + + const callbacks = { + onSubmit: useCallback((e) => { + e.preventDefault() + dispatch(commentsActions.post(newComment)) + }), + 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'); @@ -50,13 +78,39 @@ function CommentsList() {

Комментарии ({selectRedux.comments.count})

{
    {options.comments.map(comment => ( -
  • - +
  • + callbacks.onAnswer(comment.id) } /> + {select.exist + ? currentComment === comment.id && + : currentComment === comment.id && + }
  • ) )}
} - + {select.exist + ? currentComment === params.id && + : currentComment === params.id && + }
); } 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/store-redux/comments/actions.js b/src/store-redux/comments/actions.js index 8dbdbed41..658d96849 100644 --- a/src/store-redux/comments/actions.js +++ b/src/store-redux/comments/actions.js @@ -6,14 +6,14 @@ export default { */ 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) { //Ошибка загрузки @@ -21,4 +21,27 @@ export default { } }; }, + + 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?lang=ru&fields=%2A`, + 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 index 8d8e17aca..19b24b369 100644 --- a/src/store-redux/comments/reducer.js +++ b/src/store-redux/comments/reducer.js @@ -19,6 +19,12 @@ function reducer(state = initialState, action) { 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: // Нет изменений From 07f786a995778b7f16e7df73bb62c3820b34713a Mon Sep 17 00:00:00 2001 From: Mikhail Morozov Date: Fri, 18 Apr 2025 10:49:19 +0300 Subject: [PATCH 3/5] fix: refactoring comment-form and comment-list --- src/components/comment-form/index.js | 8 ++++- src/components/comments-list/index.js | 50 +++++++++++++++------------ src/store-redux/comments/actions.js | 2 +- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/components/comment-form/index.js b/src/components/comment-form/index.js index 627495d22..b822dedef 100644 --- a/src/components/comment-form/index.js +++ b/src/components/comment-form/index.js @@ -17,7 +17,13 @@ function CommentForm({ title, onSubmit, submitTitle, onCancel, onChange, value } onChange={e => onChange(e.target.value)}>
+ {value && } +
); diff --git a/src/components/comments-list/index.js b/src/components/comments-list/index.js index 6c47da08a..bd61db397 100644 --- a/src/components/comments-list/index.js +++ b/src/components/comments-list/index.js @@ -56,9 +56,12 @@ function CommentsList() { const callbacks = { onSubmit: useCallback((e) => { - e.preventDefault() + 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})) }), @@ -86,31 +89,32 @@ function CommentsList() { callbacks.onAnswer(comment.id) } /> - {select.exist - ? currentComment === comment.id && - : currentComment === comment.id && - } + {currentComment === comment.id && (select.exist + ? + : + )} ) )} } - {select.exist - ? currentComment === params.id && - : currentComment === params.id && - } + {currentComment === params.id && (select.exist + ? + : + )}
); } diff --git a/src/store-redux/comments/actions.js b/src/store-redux/comments/actions.js index 658d96849..c1f599058 100644 --- a/src/store-redux/comments/actions.js +++ b/src/store-redux/comments/actions.js @@ -28,7 +28,7 @@ export default { try { const res = await services.api.request({ - url: `/api/v1/comments?lang=ru&fields=%2A`, + url: `/api/v1/comments`, method: 'POST', body: JSON.stringify({ "_id": "", From cd1df6e780b8b47c76b183ac3a2af7043c6b5f52 Mon Sep 17 00:00:00 2001 From: Mikhail Morozov Date: Fri, 18 Apr 2025 15:40:39 +0300 Subject: [PATCH 4/5] feat: enabled the multilingual service --- src/app/article/index.js | 6 +-- src/app/main/index.js | 4 +- src/components/article-card/index.js | 8 ++-- src/components/comment-form/index.js | 7 +-- src/components/comment/index.js | 9 +++- src/components/comments-list/index.js | 18 ++++--- src/hooks/use-translate.js | 31 ++++++++++-- src/i18n/context.js | 30 ------------ src/i18n/index.js | 69 +++++++++++++++++++++++++++ src/i18n/translate.js | 21 -------- src/i18n/translations/en.json | 12 ++++- src/i18n/translations/ru.json | 12 ++++- src/index.js | 3 -- src/services.js | 12 +++++ 14 files changed, 163 insertions(+), 79 deletions(-) delete mode 100644 src/i18n/context.js create mode 100644 src/i18n/index.js delete mode 100644 src/i18n/translate.js diff --git a/src/app/article/index.js b/src/app/article/index.js index 59e4c0aff..cc9fadab7 100644 --- a/src/app/article/index.js +++ b/src/app/article/index.js @@ -21,13 +21,14 @@ function Article() { 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 => ({ @@ -37,7 +38,6 @@ function Article() { shallowequal, ); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект - const { t } = useTranslate(); const callbacks = { // Добавление в корзину 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)} ₽