diff --git a/src/components/textarea/index.js b/src/components/textarea/index.js
new file mode 100644
index 000000000..0f59da438
--- /dev/null
+++ b/src/components/textarea/index.js
@@ -0,0 +1,34 @@
+import React, { memo, useCallback, useState } from 'react';
+import Button from '../button';
+import './style.css';
+
+const Textarea = ({
+ // postComment = () => {},
+ title = '',
+ parentType = 'comment',
+ onPost = () => {},
+ onCancel = () => {},
+ postCommentText = '',
+ setPostCommentText = () => {},
+ t = z => {},
+}) => {
+
+ const callbacks = {
+ onChange: useCallback((e) => {
+ setPostCommentText(e.target.value);
+ }, [postCommentText]),
+ }
+
+ return (
+
+
{title}
+
+
+
+ {parentType === 'comment' && }
+
+
+ );
+};
+
+export default memo(Textarea);
diff --git a/src/components/textarea/style.css b/src/components/textarea/style.css
new file mode 100644
index 000000000..a08a4e170
--- /dev/null
+++ b/src/components/textarea/style.css
@@ -0,0 +1,19 @@
+.textarea-c {
+ margin-bottom: 24px;
+ textarea {
+ width: 100%;
+ margin: 16px 0;
+ border: 1px solid #D3D3D3;
+ resize: none;
+ border-radius: 4px;
+ padding: 8px 12px;
+ min-height: 88px;
+ font-family: var(--font-family);
+ }
+}
+
+.textarea-c-buttons {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+}
diff --git a/src/config.js b/src/config.js
index 67c72f734..fec65f744 100644
--- a/src/config.js
+++ b/src/config.js
@@ -1,4 +1,5 @@
const isProduction = process.env.NODE_ENV === 'production';
+import * as translations from './i18n/translations';
/**
* Настройки сервисов
@@ -18,6 +19,11 @@ const config = {
api: {
baseUrl: '',
},
+
+ i18n: {
+ defaultLang: 'ru',
+ translations,
+ }
};
export default config;
diff --git a/src/containers/catalog-filter/index.js b/src/containers/catalog-filter/index.js
index 2eeca7564..9630ed478 100644
--- a/src/containers/catalog-filter/index.js
+++ b/src/containers/catalog-filter/index.js
@@ -10,6 +10,8 @@ import listToTree from '../../utils/list-to-tree';
import Button from '../../components/button';
function CatalogFilter() {
+ const { t, lang } = useTranslate();
+
const store = useStore();
const select = useSelector(state => ({
@@ -23,7 +25,7 @@ function CatalogFilter() {
// Сортировка
onSort: useCallback(sort => store.actions.catalog.setParams({ sort }), [store]),
// Поиск
- onSearch: useCallback(query => store.actions.catalog.setParams({ query, page: 1 }), [store]),
+ onSearch: useCallback(query => store.actions.catalog.setParams({ query, page: 1 }), [store, lang]),
// Сброс
onReset: useCallback(() => store.actions.catalog.resetParams(), [store]),
// Фильтр по категории
@@ -33,7 +35,7 @@ function CatalogFilter() {
category,
page: 1,
}),
- [store],
+ [store, lang],
),
};
@@ -41,28 +43,26 @@ function CatalogFilter() {
// Варианты сортировок
sort: useMemo(
() => [
- { value: 'order', title: 'По порядку' },
- { value: 'title.ru', title: 'По именованию' },
- { value: '-price', title: 'Сначала дорогие' },
- { value: 'edition', title: 'Древние' },
+ { value: 'order', title: t('filter.sort.order') },
+ { value: `title.${lang}`, title: t('filter.sort.title') },
+ { value: '-price', title: t('filter.sort.price') },
+ { value: 'edition', title: t('filter.sort.edition') },
],
- [],
+ [lang],
),
// Категории для фильтра
categories: useMemo(
() => [
- { value: '', title: 'Все' },
+ { value: '', title: t('filter.categories-all') },
...treeToList(listToTree(select.categories), (item, level) => ({
value: item._id,
title: '- '.repeat(level) + item.title,
})),
],
- [select.categories],
+ [select.categories, lang],
),
};
- const { t } = useTranslate();
-
return (
diff --git a/src/containers/comments/index.js b/src/containers/comments/index.js
new file mode 100644
index 000000000..eca6f2d6f
--- /dev/null
+++ b/src/containers/comments/index.js
@@ -0,0 +1,89 @@
+import React, {memo, useCallback, useEffect, useMemo, useState} from 'react';
+import CommentList from "../../components/comment-list";
+import buildCommentTree from "../../utils/buildCommentTree";
+import {useLocation, useNavigate, useParams} from "react-router-dom";
+import useInit from "../../hooks/use-init";
+import commentsActions from "../../store-redux/comments/actions";
+import {useDispatch, useSelector as useSelectorRedux} from "react-redux";
+import shallowequal from "shallowequal";
+import Spinner from "../../components/spinner";
+import useSelector from "../../hooks/use-selector";
+import useTranslate from "../../hooks/use-translate";
+import listToTree from "../../utils/list-to-tree";
+
+const Comments = () => {
+ const { t, lang } = useTranslate();
+
+ const navigate = useNavigate();
+ const location = useLocation();
+ const params = useParams();
+ const dispatch = useDispatch();
+
+ const [activeReplyId, setActiveReplyId] = useState(null);
+ const [postCommentText, setPostCommentText] = useState("");
+
+ useInit(() => {
+ dispatch(commentsActions.load(params.id));
+ }, [params.id]);
+
+ const select = useSelectorRedux(
+ state => ({
+ comments: state.comments.data,
+ commentCount: state.comments.count,
+ waiting: state.comments.waiting,
+ }),
+ shallowequal,
+ );
+
+ const sessionUserId = useSelector((state) => state.session?.user._id);
+
+ const options = {
+ commentTree: useMemo(() => {
+ return (listToTree(select.comments, '_id', {rootType: 'article'})[0])?.children;
+ }, [select.comments]),
+ };
+
+ const callbacks = {
+ postComment: useCallback(() => {
+ if (postCommentText.trim() !== "") {
+ dispatch(commentsActions.post(
+ postCommentText,
+ activeReplyId ? activeReplyId : params.id,
+ activeReplyId ? 'comment' : 'article',
+ )).then(() => {
+ dispatch(commentsActions.load(params.id));
+ setPostCommentText('');
+ setActiveReplyId(null);
+ }).catch((err) => {
+ console.error("Ошибка при отправке комментария", err);
+ });
+ } else {
+ alert('Введите текст комментария');
+ }
+ }, [dispatch, postCommentText, activeReplyId, params.id]),
+ goToLogin: useCallback(() => {
+ navigate('/login', { state: { back: location.pathname } });
+ }, [navigate, location]),
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default memo(Comments);
diff --git a/src/containers/locale-select/index.js b/src/containers/locale-select/index.js
index a60df2a9a..fdcb3f90e 100644
--- a/src/containers/locale-select/index.js
+++ b/src/containers/locale-select/index.js
@@ -5,7 +5,7 @@ import useTranslate from '../../hooks/use-translate';
import Select from '../../components/select';
function LocaleSelect() {
- const { lang, setLang } = useTranslate();
+ const { t, lang, setLang } = useTranslate();
const options = {
lang: useMemo(
diff --git a/src/global.css b/src/global.css
index e81695d21..7c53b9005 100644
--- a/src/global.css
+++ b/src/global.css
@@ -13,6 +13,7 @@
--odd-item: #6B4ACB08;
--close: #878787;
--filter-border: #D3D3D3;
+ --user-comment: #4B5563;
--font-family: 'Golos Text';
--second-font-family: 'Montserrat Alternates';
diff --git a/src/hooks/use-translate.js b/src/hooks/use-translate.js
index bdcbf1071..ffc353e13 100644
--- a/src/hooks/use-translate.js
+++ b/src/hooks/use-translate.js
@@ -1,9 +1,26 @@
-import { useCallback, useContext } from 'react';
-import { I18nContext } from '../i18n/context';
+import {useCallback, useEffect, useState} from 'react';
+import useServices from "./use-services";
/**
* Хук возвращает функцию для локализации текстов, код языка и функцию его смены
*/
export default function useTranslate() {
- return useContext(I18nContext);
+ const { i18n } = useServices();
+ const [lang, setLangState] = useState(i18n.getLang());
+
+ useEffect(() => {
+ return i18n.subscribe(newLang => {
+ setLangState(newLang);
+ });
+ }, [i18n]);
+
+ const t = useCallback((key, plural, langCode) => {
+ return i18n.translate(key, plural, langCode);
+ }, [i18n, lang]);
+
+ const setLang = useCallback((newLang) => {
+ i18n.setLang(newLang);
+ }, [i18n]);
+
+ return { t, lang, setLang };
}
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..b2fb3fa08
--- /dev/null
+++ b/src/i18n/index.js
@@ -0,0 +1,45 @@
+class I18nService {
+ /**
+ * @param services {Services} Менеджер сервисов
+ * @param config {Object}
+ */
+ constructor(services, config = {}) {
+ this.services = services;
+ this.config = config;
+
+ this.currentLang = config.defaultLang ;//
+ this.translations = config.translations || {};
+ this.listeners = new Set();
+ }
+
+ subscribe(callback) {
+ this.listeners.add(callback);
+ return () => this.listeners.delete(callback);
+ }
+
+ getLang() {
+ return this.currentLang;
+ }
+
+ setLang(langCode) {
+ if (this.currentLang === langCode) return;
+
+ this.currentLang = langCode;
+
+ this.services.api.setLangHeader(langCode);
+
+ this.listeners.forEach((cb) => cb(langCode));
+ }
+
+ translate(key, plural, lang = this.currentLang) {
+ const translation = this.translations?.[lang]?.[key];
+
+ if (typeof plural !== 'undefined') {
+ const pluralForm = new Intl.PluralRules(lang).select(plural);
+ return translation?.[pluralForm] || key;
+ }
+ return translation || key;
+ }
+}
+
+export default I18nService;
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..d0275ab6a 100644
--- a/src/i18n/translations/en.json
+++ b/src/i18n/translations/en.json
@@ -5,7 +5,7 @@
"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",
@@ -13,7 +13,35 @@
"one": "article",
"other": "articles"
},
+
+
"article.add": "Add",
+
+ "article.madeIn": "Made in",
+ "article.category": "Category",
+ "article.edition": "Year of release",
+ "article.price": "Price",
+
+ "comments.title": "Comments",
+ "comments.reply": "Reply",
+ "comments.new-comment-title": "New comment",
+ "comments.unAuth-btn": "Log in",
+ "comments.unAuth-text": " to be able to comment",
+ "comments.new-reply-title": "New Reply",
+ "comments.button-send": "Submit",
+ "comments.button-cancel": "Cancel",
+
+ "filter.sort.order": "In order",
+ "filter.sort.title": "By naming",
+ "filter.sort.price": "Dear ones first",
+ "filter.sort.edition": "Ancient",
+ "filter.categories-all": "All",
+ "filter.search": "Search",
+
+ "profile.title": "Profile",
+ "profile.name": "Name",
+ "profile.phone": "Phone",
+
"filter.reset": "Reset",
"auth.title": "Sign In",
"auth.login": "Login",
diff --git a/src/i18n/translations/ru.json b/src/i18n/translations/ru.json
index a18fb845e..af845f139 100644
--- a/src/i18n/translations/ru.json
+++ b/src/i18n/translations/ru.json
@@ -5,7 +5,7 @@
"basket.open": "Перейти",
"basket.close": "Закрыть",
"basket.inBasket": "В корзине",
- "basket.empty": "пусто",
+ "basket.empty": "Пусто",
"basket.total": "Итого",
"basket.unit": "шт",
"basket.delete": "Удалить",
@@ -16,6 +16,32 @@
"other": "товара"
},
"article.add": "Добавить",
+
+ "article.madeIn": "Страна производитель",
+ "article.category": "Категория",
+ "article.edition": "Год выпуска",
+ "article.price": "Цена",
+
+ "comments.title": "Комментарии",
+ "comments.reply": "Ответить",
+ "comments.new-comment-title": "Новый комментарий",
+ "comments.unAuth-btn": "Войдите",
+ "comments.unAuth-text": ", чтобы иметь возможность комментировать",
+ "comments.new-reply-title": "Новый ответ",
+ "comments.button-send": "Отправить",
+ "comments.button-cancel": "Отмена",
+
+ "filter.sort.order": "По порядку",
+ "filter.sort.title": "По именованию",
+ "filter.sort.price": "Сначала дорогие",
+ "filter.sort.edition": "Древние",
+ "filter.categories-all": "Все",
+ "filter.search": "Поиск",
+
+ "profile.title": "Профиль",
+ "profile.name": "Имя",
+ "profile.phone": "Телефон",
+
"filter.reset": "Сбросить",
"auth.title": "Вход",
"auth.login": "Логин",
diff --git a/src/index.js b/src/index.js
index f49db385a..9404d09eb 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..889c69fa6 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) {
@@ -18,6 +19,13 @@ class Services {
return this._api;
}
+ get i18n() {
+ if (!this._i18n) {
+ this._i18n = new I18nService(this, this.config.i18n);
+ }
+ return this._i18n;
+ }
+
/**
* Сервис Store
* @returns {Store}
diff --git a/src/store-redux/comments/actions.js b/src/store-redux/comments/actions.js
new file mode 100644
index 000000000..ccaa10223
--- /dev/null
+++ b/src/store-redux/comments/actions.js
@@ -0,0 +1,45 @@
+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 } });
+ // console.log('getState: ', getState());
+ // console.log('services: ', services);
+ } catch (e) {
+ //Ошибка загрузки
+ dispatch({ type: 'comments/load-error', payload: { error: e ? e : 'true' } });
+ }
+ };
+ },
+
+ post: (text, parentId, parentType = "article") => {
+ return async (dispatch, getState, services) => {
+ dispatch({ type: 'comments/post-start' });
+ try {
+ const res = await services.api.request({
+ url: `/api/v1/comments`,
+ method: 'POST',
+ body: JSON.stringify({
+ text: text,
+ parent: {_id: parentId, _type: parentType}
+ })
+ });
+ dispatch({ type: 'comments/post-success', });
+ return res;
+ } catch (e) {
+ dispatch({ type: 'comments/post-error', payload: { error: e || 'true' } });
+ throw e;
+ }
+ }
+ }
+};
diff --git a/src/store-redux/comments/reducer.js b/src/store-redux/comments/reducer.js
new file mode 100644
index 000000000..82d25703a
--- /dev/null
+++ b/src/store-redux/comments/reducer.js
@@ -0,0 +1,38 @@
+// Начальное состояние
+export const initialState = {
+ data: [],
+ count: 0,
+ error: null,
+ waiting: false, // признак ожидания загрузки
+ postWaiting: false,
+ postError: null,
+};
+
+// Обработчик действий
+function reducer(state = initialState, action) {
+ switch (action.type) {
+ case 'comments/load-start':
+ return { ...state, data: [], count: 0, error: null, 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, error: action.payload.error, waiting: false }; //@todo текст ошибки сохранять?
+
+ case 'comments/post-start':
+ return { ...state, postWaiting: true, postError: null, };
+
+ case 'comments/post-success':
+ return { ...state, postWaiting: false, };
+
+ case 'comments/post-error':
+ return { ...state, postWaiting: false, postError: action.payload.error, };
+
+ 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/buildCommentTree.js b/src/utils/buildCommentTree.js
new file mode 100644
index 000000000..29c5410a1
--- /dev/null
+++ b/src/utils/buildCommentTree.js
@@ -0,0 +1,21 @@
+export default function buildCommentTree(comments = []) {
+ const map = {};
+ const roots = [];
+
+ comments.forEach(comment => {
+ map[comment._id] = { ...comment, children: [] };
+ });
+
+ comments.forEach(comment => {
+ if (comment.parent._type === 'comment') {
+ const parent = map[comment.parent._id];
+ if (parent) {
+ parent.children.push(map[comment._id]);
+ }
+ } else {
+ roots.push(map[comment._id]);
+ }
+ });
+
+ return roots;
+};
diff --git a/src/utils/format-date.js b/src/utils/format-date.js
new file mode 100644
index 000000000..0b62b03c7
--- /dev/null
+++ b/src/utils/format-date.js
@@ -0,0 +1,20 @@
+export default function (isoString, locale = 'ru') {
+ const date = new Date(isoString);
+
+ const options = {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ };
+
+ const formatted = new Intl.DateTimeFormat(locale, options).format(date);
+
+ return locale === 'ru'
+ ? formatted.replace('г.', '')
+ : formatted.replace(',', '')
+ ;
+}
+
+//.replace(',', ' в').replace('г.', '')
diff --git a/src/utils/list-to-tree/index.js b/src/utils/list-to-tree/index.js
index fb4d20a66..3038152c5 100644
--- a/src/utils/list-to-tree/index.js
+++ b/src/utils/list-to-tree/index.js
@@ -4,32 +4,53 @@
* @param [key] {String} Свойство с первичным ключом
* @returns {Array} Корневые узлы
*/
-export default function listToTree(list, key = '_id') {
+export default function listToTree(
+ list,
+ key = '_id',
+ options = {},
+) {
+ const {
+ parentKey = 'parent',
+ rootType = null,
+ rootId = null
+ } = options;
+
let trees = {};
let roots = {};
+
for (const item of list) {
- // Добавление элемента в индекс узлов и создание свойства children
- if (!trees[item[key]]) {
- trees[item[key]] = item;
- trees[item[key]].children = [];
- // Ещё никто не ссылался, поэтому пока считаем корнем
- roots[item[key]] = trees[item[key]];
+ const itemId = item[key];
+ const parent = item?.[parentKey];
+ const parentId = parent?.[key];
+ const parentType = parent?._type;
+
+ // Создание элемента и children
+ if (!trees[itemId]) {
+ trees[itemId] = { ...item, children: [] };
+ roots[itemId] = trees[itemId];
} else {
- trees[item[key]] = Object.assign(trees[item[key]], item);
+ trees[itemId] = Object.assign(trees[itemId], item);
}
- // Если элемент имеет родителя, то добавляем его в подчиненные родителя
- if (item.parent?.[key]) {
- // Если родителя ещё нет в индексе, то индекс создаётся, ведь _id родителя известен
- if (!trees[item.parent[key]]) {
- trees[item.parent[key]] = { children: [] };
- roots[item.parent[key]] = trees[item.parent[key]];
+ // Если есть родитель
+ if (parentId) {
+ if (!trees[parentId]) {
+ trees[parentId] = { children: [] };
+ roots[parentId] = trees[parentId];
}
- // Добавления в подчиненные родителя
- trees[item.parent[key]].children.push(trees[item[key]]);
- // Так как элемент добавлен к родителю, то он уже не является корневым
- if (roots[item[key]]) delete roots[item[key]];
+ trees[parentId].children.push(trees[itemId]);
+
+ if (roots[itemId]) delete roots[itemId];
+ }
+
+ // Только если rootType задан — проверяем его
+ if (
+ rootType !== null &&
+ (parentType !== rootType || parentId !== rootId)
+ ) {
+ if (roots[itemId]) delete roots[itemId];
}
}
+
return Object.values(roots);
}