@@ -54,6 +61,7 @@ ItemBasket.propTypes = {
labelCurr: PropTypes.string,
labelDelete: PropTypes.string,
labelUnit: PropTypes.string,
+ locale: PropTypes.string,
};
export default memo(ItemBasket);
diff --git a/src/components/ladder-list/index.js b/src/components/ladder-list/index.js
new file mode 100644
index 000000000..07a9abdc0
--- /dev/null
+++ b/src/components/ladder-list/index.js
@@ -0,0 +1,49 @@
+import { memo } from 'react';
+import PropTypes from 'prop-types';
+import './style.css';
+
+function LadderList({
+ list,
+ renderItem = item => {},
+ indentMaxPx = 40,
+ maxLevel = 15
+ }) {
+
+ function clampLevel(level) {
+ const safeLevel = typeof level === 'number' ? level : 0;
+ return Math.max(0, Math.min(safeLevel, maxLevel));
+ }
+
+
+ return (
+
+ {list.map(item => (
+ -
+ {renderItem(item)}
+
+ ))}
+
+ );
+}
+
+LadderList.propTypes = {
+ list: PropTypes.arrayOf(
+ PropTypes.shape({
+ _id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ level: PropTypes.number,
+ }),
+ ).isRequired,
+ renderItem: PropTypes.func.isRequired,
+ maxLevel: PropTypes.number,
+ indentPercent: PropTypes.number,
+ indentMaxPx: PropTypes.number,
+};
+
+export default memo(LadderList);
diff --git a/src/components/ladder-list/style.css b/src/components/ladder-list/style.css
new file mode 100644
index 000000000..b62e130d6
--- /dev/null
+++ b/src/components/ladder-list/style.css
@@ -0,0 +1,15 @@
+.LadderList {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.LadderList-item {
+ /* padding-left: calc(var(--level, 0) * 40px);*/
+ --level: 0;
+ --indent: 0px;
+ padding-left: calc(var(--level) * var(--indent));
+}
diff --git a/src/components/list/index.js b/src/components/list/index.js
index e84d13b57..4f005614e 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 = item => {} }) {
@@ -21,7 +20,7 @@ List.propTypes = {
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}),
).isRequired,
- renderItem: PropTypes.func,
+ renderItem: PropTypes.func.isRequired,
};
export default memo(List);
diff --git a/src/components/profile-card/index.js b/src/components/profile-card/index.js
index 121610ff4..addb354e0 100644
--- a/src/components/profile-card/index.js
+++ b/src/components/profile-card/index.js
@@ -3,22 +3,24 @@ import PropTypes from 'prop-types';
import { cn as bem } from '@bem-react/classname';
import './style.css';
-function ProfileCard({ data }) {
+function ProfileCard({ data, t = text => text }) {
const cn = bem('ProfileCard');
+
+
return (
-
Профиль
+
{t('profile.title')}
-
Имя:
+
{t('profile.name')}:
{data?.profile?.name}
-
Телефон:
+
{t('profile.phone')}:
{data?.profile?.phone}
-
Email:
+
{t('profile.mail')}:
{data?.email}
@@ -27,6 +29,7 @@ function ProfileCard({ data }) {
ProfileCard.propTypes = {
data: PropTypes.object.isRequired,
+ t: PropTypes.func.isRequired,
};
export default memo(ProfileCard);
diff --git a/src/config.js b/src/config.js
index 67c72f734..4f610797d 100644
--- a/src/config.js
+++ b/src/config.js
@@ -15,6 +15,10 @@ const config = {
},
},
},
+ i18n: {
+ // Названия локали в АПИ
+ localeHeader: 'Accept-Language',
+ },
api: {
baseUrl: '',
},
diff --git a/src/containers/article-comments/index.js b/src/containers/article-comments/index.js
new file mode 100644
index 000000000..abab9fed6
--- /dev/null
+++ b/src/containers/article-comments/index.js
@@ -0,0 +1,144 @@
+import { memo, useCallback, useMemo, useState, useEffect } from 'react';
+import './style.css';
+import { useDispatch, useSelector } from 'react-redux';
+import { useLocation } from 'react-router-dom';
+import commentsActions from '../../store-redux/comments/actions';
+import shallowequal from 'shallowequal';
+import useSelectorStore from '../../hooks/use-selector';
+import CommentAction from '../../components/comment-action';
+import LadderList from '../../components/ladder-list';
+import CommentsLayout from '../../components/comments-layout';
+import CommentItem from '../../components/comment-item';
+import Spinner from '../../components/spinner';
+import useLocale from '../../hooks/use-locale';
+import { listFromFlat } from '../../utils/list-from-flat/list-from-flat';
+
+function ArticleComments() {
+
+ const { t, locale } = useLocale()
+ const [addComment, setAddComment] = useState(false);
+ const [commentValue, setCommentValue] = useState('');
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const replyTo = location.state?.replyTo;
+ const backLink = location.pathname;
+
+
+ const select = useSelector(
+ state => ({
+ article: state.article.data,
+ waiting: state.article.waiting,
+ comments: state.comments.data,
+ commentsCount: state.comments.count,
+ commentsWaiting: state.comments.waiting,
+ }),
+ shallowequal,
+ ); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект
+
+
+ const selectStore = useSelectorStore(state => ({
+ user: state.session.user,
+ exists: state.session.exists,
+ }));
+
+ const comments = useMemo(
+ () => listFromFlat(select.comments || []),
+ [select.comments]
+ );
+
+ useEffect(() => {
+ if (!addComment) return;
+
+ const target = document.getElementById('reply');
+ if (target) {
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ }, [select.comments, addComment]);
+
+
+ useEffect(() => {
+ if (!replyTo) return;
+ const target = document.getElementById(`comment-${replyTo}`);
+ if (target) {
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }}, [replyTo]);
+
+ const callbacks = {
+ addToAnswer: useCallback(comment => {
+ setAddComment(true)
+ setCommentValue(`Мой ответ для ${comment.author} `)
+ dispatch(commentsActions.setReplyAction(comment, selectStore.user))
+ }, []),
+
+ cancelToAnswer: useCallback(() => {
+ setAddComment(false)
+ setCommentValue('');
+ dispatch(commentsActions.removeReplyAction())
+ }, []),
+
+ addApiToAnswer: useCallback((parent) => {
+ dispatch(commentsActions.addComment(parent, commentValue));
+ }, [parent, commentValue]),
+ };
+
+ const renders = {
+ item: useCallback(
+ item => (
+ <>
+ {item.value !== 'reply' &&
callbacks.addToAnswer(item)}
+ onCancelReply={() => callbacks.cancelToAnswer()}
+ t={t}
+ locale={locale}
+ />}
+ {item.value === 'reply' &&
+ renders.action({
+ id: item.parent._id,
+ isReply: true,
+ onAdd: () =>
+ callbacks.addApiToAnswer(item.parent),
+ cancel: callbacks.cancelToAnswer,
+ })}
+ >
+ ),
+ [addComment, commentValue, selectStore, t, callbacks],
+ ),
+
+ action: ({ isReply, onAdd, cancel, id }) => (
+
+ ),
+ };
+
+ return (
+
+ {comments && }
+ {!addComment && renders.action({
+ isReply: false,
+ onAdd: () =>
+ callbacks.addApiToAnswer({
+ _id: select.article._id,
+ _type: 'article',
+ }),
+ cancel: callbacks.cancelToAnswer,
+ })}
+
+
+
+ );
+}
+
+export default memo(ArticleComments);
diff --git a/src/containers/article-comments/style.css b/src/containers/article-comments/style.css
new file mode 100644
index 000000000..c04d2f553
--- /dev/null
+++ b/src/containers/article-comments/style.css
@@ -0,0 +1,38 @@
+.CommentsBox {
+ display: flex;
+ flex-direction: column;
+ padding-top: 40px;
+}
+
+.CommentsBox-title {
+ padding-bottom: 24px;
+ margin-top: 0;
+ font-size: 32px;
+ font-family: var(--second-font-family);
+ line-height: 40px;
+}
+
+.CommentsBox-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.CommentsBox-item {
+ padding-left: calc(var(--level, 0) * 40px);
+}
+
+
+.CommentsBox-alert {
+
+ a {
+ color: var(--primary);
+ }
+
+ a:hover {
+ opacity: 0.6;
+ }
+}
diff --git a/src/containers/catalog-filter/index.js b/src/containers/catalog-filter/index.js
index 2eeca7564..070a2a9b3 100644
--- a/src/containers/catalog-filter/index.js
+++ b/src/containers/catalog-filter/index.js
@@ -1,5 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
-import useTranslate from '../../hooks/use-translate';
import useStore from '../../hooks/use-store';
import useSelector from '../../hooks/use-selector';
import Select from '../../components/select';
@@ -8,10 +7,12 @@ import SideLayout from '../../components/side-layout';
import treeToList from '../../utils/tree-to-list';
import listToTree from '../../utils/list-to-tree';
import Button from '../../components/button';
+import useLocale from '../../hooks/use-locale';
+
function CatalogFilter() {
const store = useStore();
-
+ const { t, locale } = useLocale()
const select = useSelector(state => ({
sort: state.catalog.params.sort,
query: state.catalog.params.query,
@@ -41,17 +42,17 @@ function CatalogFilter() {
// Варианты сортировок
sort: useMemo(
() => [
- { value: 'order', title: 'По порядку' },
- { value: 'title.ru', title: 'По именованию' },
- { value: '-price', title: 'Сначала дорогие' },
- { value: 'edition', title: 'Древние' },
+ { value: 'order', title: t('sort.order') },
+ { value: 'title.ru', title: t('sort.name') },
+ { value: '-price', title: t('sort.expensive') },
+ { value: 'edition', title: t('sort.old') },
],
- [],
+ [locale],
),
// Категории для фильтра
categories: useMemo(
() => [
- { value: '', title: 'Все' },
+ { value: '', title: t('filter.all') },
...treeToList(listToTree(select.categories), (item, level) => ({
value: item._id,
title: '- '.repeat(level) + item.title,
@@ -61,7 +62,7 @@ function CatalogFilter() {
),
};
- const { t } = useTranslate();
+
return (
@@ -80,7 +81,7 @@ function CatalogFilter() {
diff --git a/src/containers/catalog-list/index.js b/src/containers/catalog-list/index.js
index d6af63ead..8fbb6a6ff 100644
--- a/src/containers/catalog-list/index.js
+++ b/src/containers/catalog-list/index.js
@@ -1,11 +1,11 @@
import { memo, useCallback } from 'react';
import useStore from '../../hooks/use-store';
import useSelector from '../../hooks/use-selector';
-import useTranslate from '../../hooks/use-translate';
import Item from '../../components/item';
import List from '../../components/list';
import Pagination from '../../components/pagination';
import Spinner from '../../components/spinner';
+import useLocale from '../../hooks/use-locale';
function CatalogList() {
const store = useStore();
@@ -39,7 +39,7 @@ function CatalogList() {
),
};
- const { t } = useTranslate();
+ const { t } = useLocale()
const renders = {
item: useCallback(
diff --git a/src/containers/locale-select/index.js b/src/containers/locale-select/index.js
index a60df2a9a..008b650fd 100644
--- a/src/containers/locale-select/index.js
+++ b/src/containers/locale-select/index.js
@@ -1,11 +1,19 @@
-import { memo, useCallback, useMemo } from 'react';
-import useStore from '../../hooks/use-store';
-import useSelector from '../../hooks/use-selector';
-import useTranslate from '../../hooks/use-translate';
+import { memo, useMemo, useState, useEffect } from 'react';
import Select from '../../components/select';
+import useLocale from '../../hooks/use-locale';
function LocaleSelect() {
- const { lang, setLang } = useTranslate();
+ const { locale, setLocale } = useLocale()
+ const [currentValue, setCurrentValue] = useState('ru')
+
+ useEffect(() => {
+ setCurrentValue(locale)
+ }, [locale]);
+
+ function toggleLanguage(value) {
+ setLocale(value);
+ setCurrentValue(locale)
+ }
const options = {
lang: useMemo(
@@ -17,7 +25,7 @@ function LocaleSelect() {
),
};
- return ;
+ return ;
}
export default memo(LocaleSelect);
diff --git a/src/containers/navigation/index.js b/src/containers/navigation/index.js
index 44a2f8a4f..3ce89dc1b 100644
--- a/src/containers/navigation/index.js
+++ b/src/containers/navigation/index.js
@@ -1,12 +1,12 @@
import { memo, useCallback, useMemo } from 'react';
import useStore from '../../hooks/use-store';
import useSelector from '../../hooks/use-selector';
-import useTranslate from '../../hooks/use-translate';
import Menu from '../../components/menu';
import BasketTool from '../../components/basket-tool';
import SideLayout from '../../components/side-layout';
import { useDispatch } from 'react-redux';
import modalsActions from '../../store-redux/modals/actions';
+import useLocale from '../../hooks/use-locale';
function Navigation() {
const store = useStore();
@@ -34,7 +34,7 @@ function Navigation() {
};
// Функция для локализации текстов
- const { t } = useTranslate();
+ const { t } = useLocale()
const options = {
menu: useMemo(() => [{ key: 1, title: t('menu.main'), link: '/' }], [t]),
diff --git a/src/containers/top-head/index.js b/src/containers/top-head/index.js
index 179a51cfb..adc2349eb 100644
--- a/src/containers/top-head/index.js
+++ b/src/containers/top-head/index.js
@@ -1,13 +1,13 @@
import { memo, useCallback } from 'react';
import SideLayout from '../../components/side-layout';
import { Link, useLocation, useNavigate } from 'react-router-dom';
-import useTranslate from '../../hooks/use-translate';
import useSelector from '../../hooks/use-selector';
import useStore from '../../hooks/use-store';
import Button from '../../components/button';
+import useLocale from '../../hooks/use-locale';
function TopHead() {
- const { t } = useTranslate();
+ const { t } = useLocale()
const navigate = useNavigate();
const location = useLocation();
const store = useStore();
diff --git a/src/global.css b/src/global.css
index e81695d21..4e06a54ea 100644
--- a/src/global.css
+++ b/src/global.css
@@ -7,6 +7,7 @@
--primary-select: #6B4ACB1A;
--main-text: #1A2028;
--second-text: #FFFFFF;
+ --other-text: #666666;
--overlay: #1A202866;
--divider: #EFEFEF;
--delete: #D43B3B;
diff --git a/src/hooks/use-locale.js b/src/hooks/use-locale.js
new file mode 100644
index 000000000..a90393cca
--- /dev/null
+++ b/src/hooks/use-locale.js
@@ -0,0 +1,19 @@
+import { useContext, useEffect, useState } from 'react';
+import { ServicesContext } from '../context';
+
+export default function useLocale() {
+ const services = useContext(ServicesContext);
+ const i18n = services.i18n;
+
+ const [locale, setLocaleState] = useState(() => i18n.getLocale());
+
+ useEffect(() => {
+ const unsubscribe = i18n.subscribe(setLocaleState);
+ return () => unsubscribe();
+ }, [i18n]);
+
+ const setLocale = (val) => i18n.setLocale(val);
+ const t = (key, plural, lang) => i18n.translate(key, plural, lang);
+
+ return { locale, setLocale, t };
+}
diff --git a/src/hooks/use-translate.js b/src/hooks/use-translate.js
deleted file mode 100644
index bdcbf1071..000000000
--- a/src/hooks/use-translate.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { useCallback, useContext } from 'react';
-import { I18nContext } from '../i18n/context';
-
-/**
- * Хук возвращает функцию для локализации текстов, код языка и функцию его смены
- */
-export default function useTranslate() {
- return useContext(I18nContext);
-}
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..18fa68ba2
--- /dev/null
+++ b/src/i18n/index.js
@@ -0,0 +1,45 @@
+import baseTranslate from './translate';
+
+/**
+ * Сервис для хранения локали и перевода
+ */
+class I18nService {
+
+ constructor(services, config = {}, defaultLocale = 'ru') {
+ this.services = services;
+ this.config = config;
+ this.locale = defaultLocale;
+ this.listeners = [];
+ }
+
+ getLocale() {
+ return this.locale;
+ }
+
+ setLocale(locale) {
+ if (this.locale !== locale) {
+ this.locale = locale;
+ this._notify();
+ }
+ this.services.api.setHeader(this.config.localeHeader, locale);
+ }
+
+ translate(key, plural, locale = this.locale) {
+ return baseTranslate(locale, key, plural);
+ }
+
+ subscribe(listener) {
+ this.listeners.push(listener);
+ return () => {
+ this.listeners = this.listeners.filter(item => item !== listener);
+ };
+ }
+
+ _notify() {
+ for (const listener of this.listeners) {
+ listener(this.locale);
+ }
+ }
+}
+
+export default I18nService;
diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json
index 0ebbcf8f2..77cdfcf06 100644
--- a/src/i18n/translations/en.json
+++ b/src/i18n/translations/en.json
@@ -1,6 +1,12 @@
{
"title": "Shop",
"menu.main": "Main",
+ "filter.all": "All",
+ "sort.order": "By order",
+ "sort.name": "By name",
+ "sort.expensive": "Price: High to Low",
+ "sort.old": "Oldies",
+ "search": "Search",
"basket.title": "Cart",
"basket.open": "Open",
"basket.close": "Close",
@@ -22,5 +28,21 @@
"auth.login-placeholder": "Enter login",
"auth.password-placeholder": "Enter password",
"session.signIn": "Sign In",
- "session.signOut": "Sign Out"
+ "session.signOut": "Sign Out",
+ "comment.newReply": "New reply",
+ "comment.newComment": "New comment",
+ "comment.send": "Send",
+ "comment.cancel": "Cancel",
+ "comment.title": "Comments",
+ "comment.reply": "Reply",
+ "profile.title": "Profile",
+ "profile.name": "Name",
+ "profile.phone": "Phone",
+ "profile.mail": "Email",
+ "article.price": "Price",
+ "article.country": "Country of origin",
+ "article.category": "Category",
+ "article.releaseYear": "Release year",
+ "comment.authHint.link": "Sign in",
+ "comment.authHint.text": ", then you’ll be able to comment."
}
diff --git a/src/i18n/translations/ru.json b/src/i18n/translations/ru.json
index a18fb845e..daaa55486 100644
--- a/src/i18n/translations/ru.json
+++ b/src/i18n/translations/ru.json
@@ -1,6 +1,12 @@
{
"title": "Магазин",
"menu.main": "Главная",
+ "filter.all": "Все",
+ "sort.order": "По порядку",
+ "sort.name": "По наименованию",
+ "sort.expensive": "Сначала дорогие",
+ "sort.old": "Древние",
+ "search": "Поиск",
"basket.title": "Корзина",
"basket.open": "Перейти",
"basket.close": "Закрыть",
@@ -24,5 +30,21 @@
"auth.login-placeholder": "Введите логин",
"auth.password-placeholder": "Введите пароль",
"session.signIn": "Вход",
- "session.signOut": "Выход"
+ "session.signOut": "Выход",
+ "comment.newReply": "Новый ответ",
+ "comment.newComment": "Новый комментарий",
+ "comment.send": "Отправить",
+ "comment.cancel": "Отмена",
+ "comment.title": "Комментарии",
+ "comment.reply": "Ответить",
+ "profile.title": "Профиль",
+ "profile.name": "Имя",
+ "profile.phone": "Телефон",
+ "profile.mail": "Email",
+ "article.price": "Цена",
+ "article.country": "Страна производитель",
+ "article.category": "Категория",
+ "article.releaseYear": "Год выпуска",
+ "comment.authHint.link": "Войдите",
+ "comment.authHint.text": ", чтобы иметь возможность комментировать"
}
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..b22d0903d 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;
}
+
+ /**
+ * Сервис i18n
+ */
+ 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/article/reducer.js b/src/store-redux/article/reducer.js
index 1df8020ce..b37776d75 100644
--- a/src/store-redux/article/reducer.js
+++ b/src/store-redux/article/reducer.js
@@ -14,7 +14,7 @@ function reducer(state = initialState, action) {
return { ...state, data: action.payload.data, waiting: false };
case 'article/load-error':
- return { ...state, data: {}, waiting: false }; //@todo текст ошибки сохранять?
+ return { ...state, data: {}, waiting: false };
default:
// Нет изменений
diff --git a/src/store-redux/comments/actions.js b/src/store-redux/comments/actions.js
new file mode 100644
index 000000000..542e09d0b
--- /dev/null
+++ b/src/store-redux/comments/actions.js
@@ -0,0 +1,87 @@
+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.items, count: res.data.result.count } });
+ } catch (e) {
+ //Ошибка загрузки
+ dispatch({ type: 'comments/load-error' });
+ }
+ };
+ },
+
+ /**
+ * Загрузка комментариев по товару
+ */
+ addComment: (parent, text) => {
+ return async (dispatch, getState, services) => {
+
+ try {
+ if (!text?.trim()) return;
+ const res = await services.api.request({
+ url: `/api/v1/comments`,
+ method: 'POST',
+ body: JSON.stringify(
+ {
+ "text": text,
+ "parent": {...parent}
+ })
+ });
+
+ dispatch({
+ type: 'comments/set-reply-comment',
+ payload: res.data.result
+ });
+
+ } catch (e) {
+ //Ошибка загрузки
+ dispatch({ type: 'comments/load-error' });
+ }
+ };
+ },
+
+ setReplyAction: (comment, user) => {
+ return (dispatch) => {
+ dispatch({ type: 'comments/remove-reply-action' });
+
+ const newReply = {
+ _id: 'reply',
+ level: (comment.level + 1) || 0,
+ text: '',
+ author: {
+ profile: {
+ name: user.name,
+ },
+ _id: user._id
+ },
+ dateCreate: '',
+ parent: {
+ _id: comment.value,
+ _type: 'comment'
+ }
+ };
+
+ dispatch({
+ type: 'comments/set-reply-action',
+ payload: newReply
+ });
+ };
+ },
+
+
+ removeReplyAction: () => ({
+ type: 'comments/remove-reply-action',
+ }),
+};
diff --git a/src/store-redux/comments/reducer.js b/src/store-redux/comments/reducer.js
new file mode 100644
index 000000000..91c615397
--- /dev/null
+++ b/src/store-redux/comments/reducer.js
@@ -0,0 +1,43 @@
+// Начальное состояние
+export const initialState = {
+ data: [],
+ count: 0,
+ waiting: false, // признак ожидания загрузки
+};
+
+// Обработчик действий
+function reducer(state = initialState, action) {
+ switch (action.type) {
+ case 'comments/load-start':
+ return { ...state, data: [], count: 0, waiting: true };
+
+ case 'comments/load-success':
+ return { ...state, data: action.payload.data, count: action.payload.count, waiting: false };
+
+ case 'comments/load-error':
+ return { ...state, data: [], count: 0, waiting: false };
+
+ case 'comments/set-reply-action':
+ return { ...state,
+ data: [...state.data, action.payload] };
+
+
+ case 'comments/set-reply-comment':
+ return { ...state,
+ count: state.count + 1,
+ data: [...state.data, action.payload] };
+
+
+ case 'comments/remove-reply-action':
+ return {
+ ...state,
+ data: state.data.filter(comment => comment._id !== 'reply'),
+ };
+
+ 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/store/basket/index.js b/src/store/basket/index.js
index 8cacc17eb..a387a59b4 100644
--- a/src/store/basket/index.js
+++ b/src/store/basket/index.js
@@ -32,7 +32,7 @@ class BasketState extends StoreModule {
if (!exist) {
// Поиск товара в каталоге, чтобы его добавить в корзину.
- const res = await this.services.api.request({ url: `/api/v1/articles/${_id}` });
+ const res = await this.services.api.request({ url: `/api/v1/articles/${_id}?&lang=all` });
const item = res.data.result;
list.push({ ...item, amount: 1 }); // list уже новый, в него можно пушить.
diff --git a/src/utils/format-pretty-date.js b/src/utils/format-pretty-date.js
new file mode 100644
index 000000000..054d9d80f
--- /dev/null
+++ b/src/utils/format-pretty-date.js
@@ -0,0 +1,30 @@
+/**
+ * Форматирование даты в читаемый вид
+ * Преобразует строку даты в формат вида "12 апреля 2025 в 14:30" с учётом указанной локали
+ * @param dateString {String} Строка даты в формате, распознаваемом конструктором Date.
+ * @param [locale] {String} Локаль (код языка), по умолчанию 'ru'.
+ * @returns {String} Отформатированная строка даты.
+ * @example formatPrettyDate('2025-04-12T14:30:00') // "12 апреля 2025 в 14:30"
+ */
+export function formatPrettyDate(dateString, locale = 'ru') {
+ const date = new Date(dateString);
+
+ const incorrectLiteral = 'г. в';
+ const correctLiteral = ' в ';
+ const formatter = {
+ dateStyle: "long",
+ timeStyle: "short",
+ };
+
+ const datePart = new Intl.DateTimeFormat(locale, formatter).formatToParts(date);
+
+ const customDate = datePart.reduce((dateString, part) => {
+ // Пропускаем литерал, если это "г." (обязательно с trim что бы удалить все виды пробелов)
+ if (part.type === 'literal' && part.value.trim() === incorrectLiteral) {
+ return dateString + correctLiteral;
+ }
+ return dateString + part.value;
+ }, '');
+
+ return customDate;
+}
diff --git a/src/utils/list-from-flat/list-from-flat.js b/src/utils/list-from-flat/list-from-flat.js
new file mode 100644
index 000000000..c1328c9a2
--- /dev/null
+++ b/src/utils/list-from-flat/list-from-flat.js
@@ -0,0 +1,22 @@
+import listToTree from '../list-to-tree';
+import treeToList from '../tree-to-list';
+
+/**
+ * Преобразует список комментариев в плоский список с уровнями вложенности.
+ * @param list - список комментариев
+ * @param key - ключ идентификатора (по умолчанию "_id")
+ */
+export function listFromFlat(list, key = '_id') {
+
+ const tree = listToTree(list, key);
+
+ return treeToList(tree[0]?.children || [], (item, level) => ({
+ value: item[key],
+ level,
+ text: item.text,
+ author: item.author?.profile?.name,
+ authorId: item.author?._id,
+ dateCreate: item.dateCreate,
+ parent: item.parent,
+ }));
+}