Skip to content

Commit

Permalink
feat: add rating without saving on server
Browse files Browse the repository at this point in the history
  • Loading branch information
sashtje committed Sep 23, 2023
1 parent 2520a6f commit 2cbba63
Show file tree
Hide file tree
Showing 16 changed files with 254 additions and 5 deletions.
2 changes: 2 additions & 0 deletions extractedTranslations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Блог": "Блог",
"Валюта": "Валюта",
"Ваш возраст": "Ваш возраст",
"Ваш отзыв": "Ваш отзыв",
"Ваше имя": "Ваше имя",
"Ваше фамилия": "Ваше фамилия",
"Введите username": "Введите username",
Expand All @@ -36,6 +37,7 @@
"Нет данных": "",
"О сайте": "О сайте",
"Обновить страницу": "Обновить страницу",
"Отмена": "Отмена",
"Отменить": "Отменить",
"Отправить": "Отправить",
"Ошибка при загрузке статей": "Ошибка при загрузке статей",
Expand Down
2 changes: 2 additions & 0 deletions extractedTranslations/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Блог": "Блог",
"Валюта": "Валюта",
"Ваш возраст": "Ваш возраст",
"Ваш отзыв": "Ваш отзыв",
"Ваше имя": "Ваше имя",
"Ваше фамилия": "Ваше фамилия",
"Введите username": "Введите username",
Expand All @@ -36,6 +37,7 @@
"Нет данных": "Нет данных",
"О сайте": "О сайте",
"Обновить страницу": "Обновить страницу",
"Отмена": "Отмена",
"Отменить": "Отменить",
"Отправить": "Отправить",
"Ошибка при загрузке статей": "Ошибка при загрузке статей",
Expand Down
4 changes: 3 additions & 1 deletion public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@
"Создать статью": "Create an article",
"Профиль": "Profile",
"Админка": "Admin panel",
"У Вас нет доступа к этой странице": "You do not have access to this page"
"У Вас нет доступа к этой странице": "You do not have access to this page",
"Ваш отзыв": "Your feedback",
"Отмена": "Cancel"
}
4 changes: 3 additions & 1 deletion public/locales/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@
"Создать статью": "Создать статью",
"Профиль": "Профиль",
"Админка": "Админка",
"У Вас нет доступа к этой странице": "У Вас нет доступа к этой странице"
"У Вас нет доступа к этой странице": "У Вас нет доступа к этой странице",
"Ваш отзыв": "Ваш отзыв",
"Отмена": "Отмена"
}
1 change: 1 addition & 0 deletions src/entities/Rating/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RatingCard } from './ui/RatingCard/RatingCard';
16 changes: 16 additions & 0 deletions src/entities/Rating/ui/RatingCard/RatingCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ComponentMeta, ComponentStory } from '@storybook/react';

import { RatingCard } from './RatingCard';

export default {
title: 'shared/Rating',
component: RatingCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof RatingCard>;

const Template: ComponentStory<typeof RatingCard> = (args) => <RatingCard {...args} />;

export const Normal = Template.bind({});
Normal.args = {};
104 changes: 104 additions & 0 deletions src/entities/Rating/ui/RatingCard/RatingCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BrowserView, MobileView } from 'react-device-detect';

import { classNames } from '@/shared/lib/classNames';
import { Card } from '@/shared/ui/Card';
import { HStack, VStack } from '@/shared/ui/Stack';
import { Text } from '@/shared/ui/Text';
import { StarRating } from '@/shared/ui/StarRating';
import { Modal } from '@/shared/ui/Modal';
import { Input } from '@/shared/ui/Input';
import { Button, ButtonSize, ButtonTheme } from '@/shared/ui/Button';
import { Drawer } from '@/shared/ui/Drawer';

import cls from './RatingCard.module.scss';

interface RatingCardProps {
className?: string;
title?: string;
feedbackTitle?: string;
hasFeedback?: boolean;
onCancel?: (starsCount: number) => void;
onAccept?: (starsCount: number, feedback?: string) => void;
}

export const RatingCard = memo((props: RatingCardProps) => {
const {
className,
title,
feedbackTitle,
hasFeedback,
onCancel,
onAccept,
} = props;
const { t } = useTranslation();

const [isModalOpen, setIsModalOpen] = useState(false);
const [starsCount, setStarsCount] = useState(0);
const [feedback, setFeedback] = useState('');

const onSelectStars = useCallback((selectedStarsCount: number) => {
setStarsCount(selectedStarsCount);

if (hasFeedback) {
setIsModalOpen(true);
} else {
onAccept?.(selectedStarsCount);
}
}, [hasFeedback, onAccept]);

const acceptHandler = useCallback(() => {
setIsModalOpen(false);
onAccept?.(starsCount, feedback);
}, [feedback, starsCount, onAccept]);

const cancelHandler = useCallback(() => {
setIsModalOpen(false);
onCancel?.(starsCount);
}, [onCancel, starsCount]);

const modalContent = (
<>
<Text title={feedbackTitle} />

<Input value={feedback} onChange={setFeedback} placeholder={t('Ваш отзыв')} />
</>
);

return (
<Card className={classNames(cls.ratingCard, {}, [className])}>
<VStack align="center" gap="8">
<Text title={title} />

<StarRating size={40} onSelect={onSelectStars} />
</VStack>

<BrowserView>
<Modal isOpen={isModalOpen} lazy onClose={cancelHandler}>
<VStack gap="32" max>
{modalContent}

<HStack gap="16" max justify="end">
<Button onClick={acceptHandler}>{t('Отправить')}</Button>

<Button theme={ButtonTheme.OUTLINE_RED} onClick={cancelHandler}>{t('Отмена')}</Button>
</HStack>
</VStack>
</Modal>
</BrowserView>

<MobileView>
<Drawer isOpen={isModalOpen} lazy onClose={cancelHandler}>
<VStack gap="32" max>
{modalContent}

<Button onClick={acceptHandler} size={ButtonSize.L} fullWidth>{t('Отправить')}</Button>
</VStack>
</Drawer>
</MobileView>
</Card>
);
});

RatingCard.displayName = 'RatingCard';
3 changes: 3 additions & 0 deletions src/shared/assets/icons/star-20-20.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/shared/ui/Button/ui/Button.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,7 @@
.disabled {
opacity: 0.5;
}

.fullWidth {
width: 100%;
}
3 changes: 3 additions & 0 deletions src/shared/ui/Button/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{
size?: ButtonSize;
disabled?: boolean;
children?: ReactNode;
fullWidth?: boolean;
}

export const Button = memo((props: ButtonProps) => {
Expand All @@ -36,12 +37,14 @@ export const Button = memo((props: ButtonProps) => {
square,
disabled,
size = ButtonSize.M,
fullWidth,
...otherProps
} = props;

const mods: Mods = {
[cls.square]: square,
[cls.disabled]: disabled,
[cls.fullWidth]: fullWidth,
};

return (
Expand Down
8 changes: 6 additions & 2 deletions src/shared/ui/Icon/ui/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { classNames } from '@/shared/lib/classNames';

import cls from './Icon.module.scss';

interface IconProps {
interface IconProps extends React.SVGProps<SVGSVGElement>{
className?: string;
Svg: VFC<SVGProps<SVGSVGElement>>;
inverted?: boolean;
Expand All @@ -15,10 +15,14 @@ export const Icon = memo((props: IconProps) => {
className,
Svg,
inverted,
...otherProps
} = props;

return (
<Svg className={classNames(cls.icon, { [cls.inverted]: inverted }, [className])} />
<Svg
className={classNames(cls.icon, { [cls.inverted]: inverted }, [className])}
{...otherProps}
/>
);
});

Expand Down
2 changes: 1 addition & 1 deletion src/shared/ui/Popups/ui/Popover/ui/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const Popover = (props: PopoverProps) => {

return (
<HPopover className={classNames(commonCls.popup, {}, [className])}>
<HPopover.Button className={commonCls.trigger}>{trigger}</HPopover.Button>
<HPopover.Button className={commonCls.trigger} as="div">{trigger}</HPopover.Button>

<HPopover.Panel className={classNames(cls.panel, {}, [commonCls[direction]])}>
{children}
Expand Down
1 change: 1 addition & 0 deletions src/shared/ui/StarRating/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StarRating } from './ui/StarRating';
15 changes: 15 additions & 0 deletions src/shared/ui/StarRating/ui/StarRating.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.starIcon {
cursor: pointer;
}

.selected {
cursor: auto;
}

.normal {
fill: none;
}

.hovered {
fill: var(--primary-color);
}
16 changes: 16 additions & 0 deletions src/shared/ui/StarRating/ui/StarRating.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ComponentMeta, ComponentStory } from '@storybook/react';

import { StarRating } from './StarRating';

export default {
title: 'shared/StarRating',
component: StarRating,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof StarRating>;

const Template: ComponentStory<typeof StarRating> = (args) => <StarRating {...args} />;

export const Normal = Template.bind({});
Normal.args = {};
74 changes: 74 additions & 0 deletions src/shared/ui/StarRating/ui/StarRating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { memo, useState } from 'react';

import { classNames } from '@/shared/lib/classNames';
import StarIcon from '@/shared/assets/icons/star-20-20.svg';

import cls from './StarRating.module.scss';
import { Icon } from '../../Icon';

interface StarRatingProps {
className?: string;
onSelect?: (starsCount: number) => void;
size?: number;
selectedStars?: number;
}

const stars = [1, 2, 3, 4, 5];

export const StarRating = memo((props: StarRatingProps) => {
const {
className,
onSelect,
size = 30,
selectedStars = 0,
} = props;

const [currentStarsCount, setCurrentStarsCount] = useState(selectedStars);
const [isSelected, setIsSelected] = useState(Boolean(selectedStars));

const onHover = (starsCount: number) => () => {
if (!isSelected) {
setCurrentStarsCount(starsCount);
}
};

const onLeave = () => {
if (!isSelected) {
setCurrentStarsCount(0);
}
};

const onClick = (starsCount: number) => () => {
if (!isSelected) {
onSelect?.(starsCount);
setCurrentStarsCount(starsCount);
setIsSelected(true);
}
};

return (
<div className={classNames(cls.starRating, {}, [className])}>
{stars.map((starNumber) => (
<Icon
Svg={StarIcon}
key={starNumber}
className={classNames(
cls.starIcon,
{
[cls.hovered]: currentStarsCount >= starNumber,
[cls.selected]: isSelected,
},
[cls.normal],
)}
width={size}
height={size}
onMouseLeave={onLeave}
onMouseEnter={onHover(starNumber)}
onClick={onClick(starNumber)}
/>
))}
</div>
);
});

StarRating.displayName = 'StarRating';

0 comments on commit 2cbba63

Please sign in to comment.