Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9a4d05c
feat: add actions and reducer for comment loading, creation and handl…
SempaiDarcy Apr 17, 2025
cb766f5
feat: refactor list-to-tree function for better readability and perfo…
SempaiDarcy Apr 17, 2025
e1b3aed
feat: refactor article reducer to handle loading state more effectively
SempaiDarcy Apr 17, 2025
1e2f235
feat: add CommentForm component with styles
SempaiDarcy Apr 17, 2025
5e69e7a
feat: add CommentItem component with styles
SempaiDarcy Apr 17, 2025
4c67e41
feat: add comments loading on article page
SempaiDarcy Apr 17, 2025
22f71e2
fix: remove deprecated defaultProps usage in CommentForm
SempaiDarcy Apr 17, 2025
540d78e
feat: redirect unauthorized users to login on reply click
SempaiDarcy Apr 17, 2025
170a813
feat: added new I18n service for language management and translation
SempaiDarcy Apr 18, 2025
24e6d3c
chore: integrated I18n service into Services with Accept-Language header
SempaiDarcy Apr 18, 2025
fbd601a
chore: updated useTranslate hook to use I18n service instead of context
SempaiDarcy Apr 18, 2025
9e1b3f9
fix: replaced hardcoded labels with i18n translations in component
SempaiDarcy Apr 18, 2025
cc87d61
chore: added profile section translations to ru/en dictionaries
SempaiDarcy Apr 18, 2025
e6ff599
feat: dynamic locale options from I18n service in LocaleSelect
SempaiDarcy Apr 18, 2025
2e25c73
fix: commit
SempaiDarcy Apr 19, 2025
b575835
feat: add dynamic indentation for nested comments
SempaiDarcy Apr 19, 2025
4e8898e
fix: update comment item typography to match Figma spec (12px, weights)
SempaiDarcy Apr 19, 2025
e341566
fix: prevent visual and DOM nesting overflow in comment thread
SempaiDarcy Apr 19, 2025
3448118
feat: add forced word wrap to prevent overflow of long text in comments
SempaiDarcy Apr 19, 2025
a477609
fix: refresh user state after logout to update comment styles
SempaiDarcy Apr 19, 2025
ad13b59
feat: add AuthHint component for unauthorized reply prompt
SempaiDarcy Apr 19, 2025
5ea0e4a
fix: comment nesting margin to consistent 40px
SempaiDarcy Apr 19, 2025
e28a03a
feat: add autoscroll to reply form and correct left indent logic
SempaiDarcy Apr 19, 2025
2d7bb12
feat: Exit and reset the response form
SempaiDarcy Apr 19, 2025
2497945
feat: support custom root type in listToTree conversion
SempaiDarcy Apr 19, 2025
5c9371b
feat: implement full multilingual UI with dynamic date and label tran…
SempaiDarcy Apr 20, 2025
731f6d2
feat: add localized comment dates and auth hint message
SempaiDarcy Apr 21, 2025
98c09a4
feat: localize comment hint and improve AuthHint UX
SempaiDarcy Apr 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions src/app/article/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,29 @@ import { useDispatch, useSelector } from 'react-redux';
import shallowequal from 'shallowequal';
import articleActions from '../../store-redux/article/actions';
import HeadLayout from '../../components/head-layout';
import CommentsSection from '../../containers/comments-section';
import commentsActions from '../../store-redux/comments/actions';

function Article() {
const store = useStore();

const dispatch = useDispatch();
// Параметры из пути /articles/:id

const params = useParams();
const { locale, t } = useTranslate();

useInit(() => {
//store.actions.article.load(params.id);
dispatch(articleActions.load(params.id));
}, [params.id]);
dispatch(commentsActions.load(params.id));
}, [params.id, locale]);

const select = useSelector(
state => ({
article: state.article.data,
waiting: state.article.waiting,
}),
shallowequal,
); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект

const { t } = useTranslate();
);

const callbacks = {
// Добавление в корзину
addToBasket: useCallback(_id => store.actions.basket.addToBasket(_id), [store]),
};

Expand All @@ -48,13 +45,18 @@ function Article() {
<HeadLayout>
<TopHead />
</HeadLayout>
<Head title={select.article.title}>
<Head title={select.article?.title || '...'}>
<LocaleSelect />
</Head>
<PageLayout>
<Navigation />
<Spinner active={select.waiting}>
<ArticleCard article={select.article} onAdd={callbacks.addToBasket} t={t} />
{select.article && select.article._id && (
<>
<ArticleCard article={select.article} onAdd={callbacks.addToBasket} t={t} />
<CommentsSection articleId={select.article._id} />
</>
)}
</Spinner>
</PageLayout>
</>
Expand Down
5 changes: 2 additions & 3 deletions src/app/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@ import HeadLayout from '../../components/head-layout';

function Main() {
const store = useStore();
const { t, locale } = useTranslate();

useInit(
async () => {
await Promise.all([store.actions.catalog.initParams(), store.actions.categories.load()]);
},
[],
[locale], // чтобы данные обновлялись при смене языка
true,
);

const { t } = useTranslate();

return (
<>
<HeadLayout>
Expand Down
9 changes: 5 additions & 4 deletions src/components/article-card/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,30 @@ function ArticleCard(props) {
<div className={cn('description')}>{article.description}</div>
<div className={cn('prop-wrapper')}>
<div className={cn('prop')}>
<div className={cn('label')}>Страна производитель:</div>
<div className={cn('label')}>{t('product.manufacturer')}:</div>
<div className={cn('value')}>
{article.madeIn?.title} ({article.madeIn?.code})
</div>
</div>
<div className={cn('prop')}>
<div className={cn('label')}>Категория:</div>
<div className={cn('label')}>{t('product.category')}:</div>
<div className={cn('value')}>{article.category?.title}</div>
</div>
<div className={cn('prop')}>
<div className={cn('label')}>Год выпуска:</div>
<div className={cn('label')}>{t('product.year')}:</div>
<div className={cn('value')}>{article.edition}</div>
</div>
</div>
<div className={cn('prop', { size: 'big' })}>
<div className={cn('label')}>Цена:</div>
<div className={cn('label')}>{t('product.price')}:</div>
<div className={cn('value')}>{numberFormat(article.price)} ₽</div>
</div>
<Button style="primary" onClick={() => onAdd(article._id)} title={t('article.add')} />
</div>
);
}


ArticleCard.propTypes = {
article: PropTypes.shape({
_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
Expand Down
24 changes: 24 additions & 0 deletions src/components/auth-hint/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import useTranslate from '../../hooks/use-translate';

function AuthHint() {
const navigate = useNavigate();
const { t } = useTranslate();

const handleLoginRedirect = () => navigate('/login');

return (
<p>
<span
onClick={handleLoginRedirect}
style={{ cursor: 'pointer', color: 'var(--primary)' }}
>
{t('authHint.login')}
</span>{', '}
{t('authHint.continue')}
</p>
);
}

export default memo(AuthHint);
6 changes: 4 additions & 2 deletions src/components/basket-tool/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import { cn as bem } from '@bem-react/classname';
import numberFormat from '../../utils/number-format';
import Cart from '../../assets/icon/cart.svg';
import './style.css';
import useTranslate from "../../hooks/use-translate";

function BasketTool(props) {
const { sum = 0, amount = 0, onOpen = () => {}, t = text => text } = props;
const { sum = 0, amount = 0, onOpen = () => {} } = props;

const cn = bem('BasketTool');
const { t } = useTranslate();

return (
<div className={cn()}>
<button className={cn('action')} onClick={onOpen}>
Expand All @@ -27,7 +30,6 @@ BasketTool.propTypes = {
onOpen: PropTypes.func.isRequired,
sum: PropTypes.number,
amount: PropTypes.number,
t: PropTypes.func,
};

export default memo(BasketTool);
42 changes: 42 additions & 0 deletions src/components/comment-form/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { memo, useState } from 'react';
import PropTypes from 'prop-types';
import './style.css';
import Button from '../button';

function CommentForm({ onSubmit, onCancel, isReply }) {
const [text, setText] = useState('');

const handleSubmit = async e => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;

await onSubmit(trimmed);
setText('');
if (onCancel) onCancel();
};

return (
<form className="comment-form" onSubmit={handleSubmit} style={{ marginTop: '1rem' }}>
<span>{isReply ? 'Новый ответ' : 'Новый комментарий'}</span>
<textarea
className="comment-form__textarea"
value={text}
onChange={e => setText(e.target.value)}
required
/>
<div className="comment-form__controls" style={{ marginTop: '0.5rem' }}>
<Button type="submit" style="primary" title="Отправить" />
{onCancel && <Button style="outline" onClick={onCancel} title="Отмена" />}
</div>
</form>
);
}

CommentForm.propTypes = {
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func,
isReply: PropTypes.bool,
};

export default memo(CommentForm);
25 changes: 25 additions & 0 deletions src/components/comment-form/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.comment-form {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 182px;
margin-bottom: 1rem;
}
.comment-form span {
font-weight: 700;
}
.comment-form__textarea {
width: 100%;
height: 88px;
padding: 12px;
font-size: 16px;
line-height: 1.4;
border-color: #D3D3D3;
border-radius: 4px;
background-color: #FFFFFF;
resize: none;
}
.comment-form__controls {
display: flex;
gap: 16px;
}
128 changes: 128 additions & 0 deletions src/components/comment-item/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {memo, useEffect, useRef} from 'react';
import PropTypes from 'prop-types';
import CommentForm from '../comment-form';
import AuthHint from '../auth-hint';
import useTranslate from '../../hooks/use-translate';
import './style.css';

function CommentItem({
comment,
onReply,
onCancel,
onSend,
activeFormTargetId,
isAuthorized,
user,
level,
}) {
const isReplying = activeFormTargetId === comment._id;
const replyRef = useRef(null);

const MAX_INDENT_LEVEL = 4;
const isOwnComment = !!user?._id && comment.author?._id === user._id;

const {t, locale} = useTranslate();

useEffect(() => {
if (isReplying && replyRef.current) {
replyRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [isReplying]);


const handleReplyClick = () => {
onReply(comment._id);
};

const handleSubmitReply = text => {
onSend(text, {_id: comment._id, _type: 'comment'});
};

const date = new Date(comment.dateCreate);
const formattedDate = `${date.toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
})} ${locale === 'ru' ? 'в' : ''} ${date.toLocaleTimeString(locale, {
hour: '2-digit',
minute: '2-digit',
})}`;

return (
<div
className="comment-item"
style={{marginLeft: level > 0 ? '40px' : '0px'}}
key={String(comment._id)}
>
<div className="comment">
<div className="comment-item__info">
<strong style={{color: isOwnComment ? '#666' : undefined}}>
{comment.author?.profile?.name || 'Аноним'}
</strong>{' '}
<span>{formattedDate}</span>
</div>

<div className="comment-item__text">{comment.text}</div>

{!isReplying && (
<button
className="comment-item__reply"
style={{padding: 0, color: 'var(--primary)'}}
onClick={() => {
if (isAuthorized) {
handleReplyClick();
} else {
onReply(comment._id);
}
}}
>
{t('comments.reply')}
</button>
)}
</div>

{isReplying && (
<div ref={replyRef} style={{marginLeft: level > 0 ? '40px' : '0px'}}>
{isAuthorized ? (
<CommentForm onSubmit={handleSubmitReply} onCancel={onCancel} isReply={true}/>
) : (
<AuthHint/>
)}
</div>
)}

{level < MAX_INDENT_LEVEL &&
Array.isArray(comment.children) &&
comment.children.length > 0 && (
<>
{comment.children.map(child => (
<CommentItem
key={String(child._id)}
comment={child}
onReply={onReply}
onCancel={onCancel}
onSend={onSend}
activeFormTargetId={activeFormTargetId}
isAuthorized={isAuthorized}
user={user}
level={level + 1}
/>
))}
</>
)}
</div>
);
}

CommentItem.propTypes = {
comment: PropTypes.object.isRequired,
onReply: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onSend: PropTypes.func.isRequired,
activeFormTargetId: PropTypes.string,
isAuthorized: PropTypes.bool,
user: PropTypes.object,
level: PropTypes.number,
};

export default memo(CommentItem);
33 changes: 33 additions & 0 deletions src/components/comment-item/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.comment {
padding: 8px 0;
}

.comment-item__info strong {
font-size: 12px;
font-weight: 700;
}

.comment-item__info {
display: flex;
gap: 12px;
align-items: baseline;
}

.comment-item__info span {
font-size: 12px;
font-weight: 400;
color: #666666;
}
.comment-item__text {
font-weight: 400;
color: #000000;
font-size: 14px;
padding: 6px 0;

word-break: break-word;
overflow-wrap: anywhere;
}

.comment-item__reply button {
color:var(--primary)
}
Loading