углеводы
- {`${carbohydrates}г`}
+ {`${carbonhydrates}г`}
ккал
diff --git a/src/components/making-order-btn/index.tsx b/src/components/making-order-btn/index.tsx
index 2627ae28..d3d06043 100644
--- a/src/components/making-order-btn/index.tsx
+++ b/src/components/making-order-btn/index.tsx
@@ -1,15 +1,19 @@
import React from 'react';
import styles from './making-order-btn.module.scss';
-import Button from '@components/Button';
+import Button from '@components/button';
+import type { ButtonProps } from '@components/button';
-interface MakingOrderBtnProps {
- onClick?: () => void;
-}
+interface MakingOrderBtnProps extends Pick {}
-const MakingOrderBtn: React.FC = ({ onClick }) => {
+const MakingOrderBtn: React.FC = ({ onClick, disabled }) => {
return (
-
+
Нажимая на кнопку «Оформить заказ», вы соглашаетесь
с условиями обработки персональных данных, а также с условиями
diff --git a/src/components/making-order-btn/making-order-btn.module.scss b/src/components/making-order-btn/making-order-btn.module.scss
index 4daa8648..33b7852c 100644
--- a/src/components/making-order-btn/making-order-btn.module.scss
+++ b/src/components/making-order-btn/making-order-btn.module.scss
@@ -47,8 +47,8 @@
transition: 0.5s;
&:disabled {
- background-color: black;
- cursor: not-allowed;
+ background-color: $accent-color-lightest-green;
+ cursor: initial;
}
}
diff --git a/src/components/navigation-bar/index.tsx b/src/components/navigation-bar/index.tsx
index 6e4719da..23400bd6 100644
--- a/src/components/navigation-bar/index.tsx
+++ b/src/components/navigation-bar/index.tsx
@@ -1,36 +1,103 @@
-import React from 'react';
-import styles from './navigation-bar.module.scss';
+import React, { useEffect, useRef } from 'react';
import CustomNavLink from '@components/custom-nav-link';
+import { usePopup } from '@hooks/use-popup';
+import { useAuth } from '@hooks/use-auth';
+import styles from './navigation-bar.module.scss';
interface NavigationBarProps {
isOpen: boolean;
+ onClick: () => void;
}
-const NavigationBar: React.FC = ({ isOpen }) => {
+const NavigationBar: React.FC = ({ isOpen, onClick }) => {
+ const { isLoggedIn } = useAuth();
+ const { handleOpenPopup } = usePopup();
+ const burgerRef = useRef(null);
+
+ const handleClick = () => {
+ onClick();
+ handleOpenPopup('openPopupLogin');
+ };
+
+ const closeBurger = (e: MouseEvent) => {
+ if (isOpen && !burgerRef.current?.contains(e.target as Node)) {
+ onClick();
+ }
+ };
+ const closeBurgerOnEsc = (e: KeyboardEvent) => {
+ console.log(e);
+ if (isOpen && e.key === 'Escape') {
+ onClick();
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener('pointerdown', closeBurger);
+ document.addEventListener('keydown', closeBurgerOnEsc);
+ return () => {
+ document.removeEventListener('pointerdown', closeBurger);
+ document.removeEventListener('keydown', closeBurgerOnEsc);
+ };
+ });
+
return (
- Войти
+ {!isLoggedIn && (
+
+ Войти
+
+ )}
Каталог
-
+
+ Корзина
+
+
О нас
-
+
Товары недели
-
+
Рецепты
-
+
Контакты
diff --git a/src/components/navigation-bar/navigation-bar.module.scss b/src/components/navigation-bar/navigation-bar.module.scss
index 49869906..7c682eb6 100644
--- a/src/components/navigation-bar/navigation-bar.module.scss
+++ b/src/components/navigation-bar/navigation-bar.module.scss
@@ -8,20 +8,15 @@
width: 100%;
background-color: #fff;
padding: 0;
- display: flex;
+ display: none;
justify-content: space-between;
align-items: center;
- z-index: 1000;
+ z-index: 150;
transition: background-color 0.7s;
-}
-.burger-icon {
- background: none;
- border: none;
- font-size: 24px;
- color: #fff;
- cursor: pointer;
- transition: color 0.7s;
+ @media screen and (width <= 768px) {
+ display: flex;
+ }
}
.navigation-bar__nav {
@@ -31,10 +26,9 @@
width: 100%;
overflow: hidden;
max-height: 0;
- transition: max-height 0.7s ease-out;
+ transition: max-height 0.4s ease-out;
border-radius: 0 0 8px 8px;
- background: #f0f0f0;
- box-shadow: 0 1px 1px 0 rgb(97 99 96 / 30%);
+ box-shadow: 0 10px 10px 0 rgb(97 99 96 / 50%);
}
.navigation-bar__button {
@@ -53,7 +47,7 @@
pointer-events: auto;
transition: 0.3s;
cursor: pointer;
- margin-top: 28px;
+ margin: 20px 0 30px;
font-size: 13px;
font-weight: 400;
line-height: 140%;
@@ -68,7 +62,7 @@
align-items: center;
flex-direction: column;
list-style: none;
- margin: 26px 0;
+ margin: 0;
width: 100%;
padding: 0 20px;
box-sizing: border-box;
@@ -78,11 +72,11 @@
position: relative;
display: flex;
justify-content: center;
- align-items: flex-start;
+ align-items: center;
text-decoration: none;
padding: 10px;
height: 48px;
- transition: background-color 0.7s;
+ transition: background-color 0.4s;
width: 100%;
box-sizing: border-box;
color: var(--active, #1a1a1a);
@@ -91,10 +85,6 @@
font-style: normal;
font-weight: 400;
line-height: 140%;
-
- &:not(:first-child) {
- margin-top: 10px;
- }
}
.navigation-bar__link::after {
@@ -107,7 +97,11 @@
background: var(--inactive, #b8b8b8);
}
+.navigation-bar__link:last-of-type::after {
+ width: 0;
+}
+
.navigation-bar__link_active {
color: $green-primary-700;
- background-color: rgb(108 108 108 / 25%);
+ background-color: $background-color-input-field;
}
diff --git a/src/components/navigation-icons/index.tsx b/src/components/navigation-icons/index.tsx
index 6077540b..5a504c00 100644
--- a/src/components/navigation-icons/index.tsx
+++ b/src/components/navigation-icons/index.tsx
@@ -3,10 +3,12 @@ import { Link } from 'react-router-dom';
import { useAuth } from '@hooks/use-auth.ts';
import styles from './navigation-icons.module.scss';
import { usePopup } from '@hooks/use-popup.ts';
+import { useCart } from '@hooks/use-cart-context';
const NavigationIcons: React.FC = () => {
const { isLoggedIn } = useAuth();
const { handleOpenPopup } = usePopup();
+ const { cartData } = useCart();
return (
@@ -20,6 +22,7 @@ const NavigationIcons: React.FC = () => {
{
(
+
+ {orderSteps.map((step, index) => (
+
+
+
+ {step.text}
+
+ {index < orderSteps.length - 1 && (
+
+ )}
+
+ ))}
+
+);
+export default OrderStatusTracker;
diff --git a/src/components/order-status-tracker/order-status-tracker.module.scss b/src/components/order-status-tracker/order-status-tracker.module.scss
new file mode 100644
index 00000000..c200b0e2
--- /dev/null
+++ b/src/components/order-status-tracker/order-status-tracker.module.scss
@@ -0,0 +1,72 @@
+@use '@scss/mixins' as *;
+@use '@scss/variables' as *;
+
+.orderStatusTracker {
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: row;
+ font-family: $ubuntu-font;
+ gap: 16px;
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 140%;
+
+ @media screen and (width <= 1024px) {
+ font-size: 14px;
+ }
+
+ @media screen and (width <= 768px) {
+ font-size: 14px;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+.orderStatusTracker__item {
+ max-width: 120px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ align-items: center;
+ text-align: center;
+
+ @media screen and (width <= 1024px) {
+ max-width: 80px;
+ }
+
+ @media screen and (width <= 768px) {
+ flex-direction: row;
+ gap: 16px;
+ max-width: 100%;
+ }
+}
+
+.orderStatusTracker__icon {
+ width: 80px;
+ aspect-ratio: 1;
+
+ @media screen and (width <= 1024px) {
+ width: 60px;
+ }
+
+ @media screen and (width <= 768px) {
+ width: 40px;
+ }
+}
+
+.orderStatusTracker__status {
+ padding: 0;
+ margin: 0;
+}
+
+.orderStatusTracker__spacer {
+ background: no-repeat center url('/src/assets/images/spacer-min.svg');
+ width: 64px;
+
+ @media screen and (width <= 768px) {
+ background: no-repeat center url('/src/assets/images/vspacer-min.svg');
+ width: 40px;
+ height: 20px;
+ }
+}
diff --git a/src/components/our-block/index.tsx b/src/components/our-block/index.tsx
index 86ddff8c..a80a5bd1 100644
--- a/src/components/our-block/index.tsx
+++ b/src/components/our-block/index.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import TopicCard from '@components/topic-card';
+import TitleArrowLink from '@components/title-arrow-link';
import styles from './our-block.module.scss';
import api from '@services/api';
import type { Recipe } from '@services/generated-api/data-contracts';
@@ -13,7 +14,9 @@ const OurBlock: React.FC = () => {
return (
-
Рецепты
+
+
+
{recipes.map((recipe) => {
return
;
diff --git a/src/components/our-block/our-block.module.scss b/src/components/our-block/our-block.module.scss
index 6b47ea42..6612a20f 100644
--- a/src/components/our-block/our-block.module.scss
+++ b/src/components/our-block/our-block.module.scss
@@ -5,16 +5,11 @@
width: 100%;
}
-.our-blog__title {
- color: var(--active, #1a1a1a);
- font-family: $ubuntu-font;
- font-size: 30px;
- font-style: normal;
- font-weight: 700;
- line-height: 140%;
+.link {
+ padding-bottom: 28px;
@media screen and (width <= 768px) {
- font-size: 24px;
+ padding-bottom: 8px;
}
}
@@ -22,7 +17,6 @@
display: flex;
justify-content: center;
gap: 20px;
- margin-top: 21px;
width: 100%;
overflow-x: auto;
diff --git a/src/components/payment-button/index.tsx b/src/components/payment-button/index.tsx
new file mode 100644
index 00000000..b7cc8b1d
--- /dev/null
+++ b/src/components/payment-button/index.tsx
@@ -0,0 +1,50 @@
+import { useState } from 'react';
+import api from '@services/api';
+import Button from '@components/button';
+import styles from './payment-button.module.scss';
+
+type PaymentButtonProps = {
+ orderId: number;
+ isCheckoutPage?: boolean;
+ buttonText?: string;
+};
+
+const PaymentButton: React.FC
= ({
+ orderId,
+ isCheckoutPage,
+ buttonText,
+}) => {
+ const [isDisabled, setIsDisabled] = useState(false);
+ const [paymentError, setPaymentError] = useState('');
+
+ const handlePayment = () => {
+ setIsDisabled(true);
+
+ if (orderId === 0) return;
+
+ api
+ .usersOrderPay(orderId)
+ .then(({ checkout_session_url }) => {
+ window.location.assign(checkout_session_url);
+ })
+ .catch(({ errors }) => {
+ setPaymentError(errors[0].detail);
+ })
+ .finally(() => setIsDisabled(false));
+ };
+
+ return (
+
+
+ {paymentError}
+
+ );
+};
+
+export default PaymentButton;
diff --git a/src/components/payment-button/payment-button.module.scss b/src/components/payment-button/payment-button.module.scss
new file mode 100644
index 00000000..56e2afd2
--- /dev/null
+++ b/src/components/payment-button/payment-button.module.scss
@@ -0,0 +1,28 @@
+@use '@scss/variables' as *;
+
+.errorText {
+ padding-top: 10px;
+ min-height: 25px;
+ text-align: center;
+ color: $error-color;
+ font-size: 13px;
+ font-weight: 300;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 12px;
+ padding-top: 3px;
+ min-height: 17px;
+ }
+}
+
+.green-button__type_narrow {
+ max-width: 140px;
+}
+
+.buttonContainer {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: max-content;
+}
diff --git a/src/components/payment/index.tsx b/src/components/payment/index.tsx
new file mode 100644
index 00000000..f9f2703f
--- /dev/null
+++ b/src/components/payment/index.tsx
@@ -0,0 +1,22 @@
+import React, { ReactNode } from 'react';
+import OurBlock from '@components/our-block';
+import styles from './payment.module.scss';
+
+type PaymentProps = {
+ children?: ReactNode;
+ adviceText: string;
+};
+
+const Payment: React.FC = ({ children, adviceText }) => {
+ return (
+
+ );
+};
+
+export default Payment;
diff --git a/src/components/payment/payment.module.scss b/src/components/payment/payment.module.scss
new file mode 100644
index 00000000..66982e7e
--- /dev/null
+++ b/src/components/payment/payment.module.scss
@@ -0,0 +1,33 @@
+@use '@scss/variables' as *;
+
+.payment {
+ display: flex;
+ flex-direction: column;
+ font-family: $ubuntu-font;
+}
+
+.payment__ourBlock {
+ padding: 100px 128px 0;
+ max-width: 1024px;
+
+ @media screen and (width <= 768px) {
+ padding: 40px 20px 0;
+ }
+
+ @media screen and (width <= 550px) {
+ padding-right: 0;
+ }
+}
+
+.payment__advice {
+ padding: 0 0 44px;
+ margin: 0;
+ font-size: 30px;
+ font-weight: 700;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ padding: 0 0 16px;
+ font-size: 18px;
+ }
+}
diff --git a/src/components/popups/popup-checkout-response/index.tsx b/src/components/popups/popup-checkout-response/index.tsx
new file mode 100644
index 00000000..333ee930
--- /dev/null
+++ b/src/components/popups/popup-checkout-response/index.tsx
@@ -0,0 +1,24 @@
+import { usePopup } from '@hooks/use-popup';
+import styles from './popup-checkout-response.module.scss';
+import Popup from '@components/popup';
+
+type PopupCheckoutResponseProps = {
+ text: string;
+ handleClose: () => void;
+};
+
+const PopupCheckoutResponse: React.FC = ({
+ text,
+ handleClose,
+}) => {
+ const { popupState } = usePopup();
+ return (
+
+
+
+ );
+};
+
+export default PopupCheckoutResponse;
diff --git a/src/components/popups/popup-checkout-response/popup-checkout-response.module.scss b/src/components/popups/popup-checkout-response/popup-checkout-response.module.scss
new file mode 100644
index 00000000..7e73327f
--- /dev/null
+++ b/src/components/popups/popup-checkout-response/popup-checkout-response.module.scss
@@ -0,0 +1,26 @@
+@use '@scss/variables' as *;
+
+.textContainer {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ max-width: 500px;
+ min-height: 200px;
+ padding: 50px;
+ font-size: 30px;
+}
+
+.text {
+ margin: 0;
+ text-align: center;
+ color: $active-text-color;
+ font-family: $ubuntu-font;
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 14px;
+ font-weight: 500;
+ }
+}
diff --git a/src/components/popups/popup-recipe/index.tsx b/src/components/popups/popup-recipe/index.tsx
index 1d481ce2..a969d58c 100644
--- a/src/components/popups/popup-recipe/index.tsx
+++ b/src/components/popups/popup-recipe/index.tsx
@@ -1,24 +1,12 @@
import React from 'react';
-import styles from './popup-recipe.module.scss';
import Popup from '@components/popup';
-import { usePopup } from '@hooks/use-popup';
import IngredientsListPopup from '@components/recipes-components/ingredients-list-popup';
import ProductsListPopup from '@components/recipes-components/products-list-popup';
+import type { RecipeIngredientsProps } from '@components/recipes-components/types';
+import { usePopup } from '@hooks/use-popup';
+import styles from './popup-recipe.module.scss';
-type RecipeIngredientsProps = {
- ingredients: {
- id: number;
- name: string;
- measure_unit: string;
- quantity: number;
- ingredient_photo: string;
- photo?: string;
- amount?: number;
- price?: number;
- }[];
-};
-
-const PopupRecipe: React.FC = ({ ingredients }) => {
+const PopupRecipe: React.FC = ({ ingredients, handleClick }) => {
const {
popupState: { openPopupRecipe },
handleClosePopup,
@@ -33,8 +21,8 @@ const PopupRecipe: React.FC = ({ ingredients }) => {
Выберите товары для добавления в корзину
diff --git a/src/components/product-card/index.tsx b/src/components/product-card/index.tsx
index 29e711db..3791b25f 100644
--- a/src/components/product-card/index.tsx
+++ b/src/components/product-card/index.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
-import Button from '@components/Button';
+import Button from '@components/button';
import styles from './product-card.module.scss';
import { BASE_URL } from '@data/constants.ts';
import LikeIcon from '@images/like-icon.svg?react';
@@ -10,14 +10,14 @@ import { usePopup } from '@hooks/use-popup';
import { useCart } from '@hooks/use-cart-context.ts';
import CheckIcon from '@images/check.svg?react';
import api from '@services/api';
-import { toMeasureUnit } from '@utils/utils';
+import { translateMeasureUnit } from '@utils/utils';
type ProductCardProps = {
cardName: string;
price: number;
final_price?: number;
weight: number;
- measureUnit?: string;
+ measureUnit: string;
cardImage: string;
category?: string;
idCard: number;
@@ -72,10 +72,13 @@ const ProductCard: React.FC
= ({
if (isInCart) {
return deleteCart(idCard);
}
- return updateCart(idCard, 1);
+ return updateCart([{ id: idCard, quantity: 1 }]);
};
- const { newMeasureUnit, newWeight } = toMeasureUnit(measureUnit, weight);
+ const { measureUnit: newMeasureUnit, amount } = translateMeasureUnit(
+ measureUnit,
+ weight
+ );
return (
@@ -101,7 +104,7 @@ const ProductCard: React.FC
= ({
{`${newWeight} ${newMeasureUnit}`}
+ >{`${amount + newMeasureUnit}`}
diff --git a/src/components/profile-components/order-status/index.tsx b/src/components/profile-components/order-status/index.tsx
index 01137a6d..bdc14a9e 100644
--- a/src/components/profile-components/order-status/index.tsx
+++ b/src/components/profile-components/order-status/index.tsx
@@ -1,19 +1,14 @@
-import styles from './order-status.module.scss';
+import clsx from 'clsx';
import doneIcon from '@images/profile/home-status.svg';
import canceledIcon from '@images/profile/cancel-status.svg';
import orderedIcon from '@images/profile/payment-status.svg';
import deliveredIcon from '@images/profile/car-status.svg';
-import clsx from 'clsx';
+import checkedIcon from '@images/profile/check-status.svg';
+import { OrderStatusType } from '@pages/profile/utils/types';
+import styles from './order-status.module.scss';
type Props = {
- status?:
- | 'Ordered'
- | 'In processing'
- | 'Collecting'
- | 'Gathered'
- | 'In delivering'
- | 'Delivered'
- | 'Completed';
+ status?: OrderStatusType;
};
const statusObj = {
@@ -33,13 +28,13 @@ const statusObj = {
style: styles.canceled,
},
Ordered: {
- text: 'Оплачен',
- image: orderedIcon,
- style: styles.ordered,
+ text: 'Оформлен',
+ image: checkedIcon,
+ style: styles.checked,
},
Gathered: { text: '', image: '', style: '' },
'In processing': { text: '', image: '', style: '' },
- 'In delivering': { text: '', image: '', style: '' },
+ 'In delivering': { text: 'Оплачен', image: orderedIcon, style: styles.ordered },
Collecting: { text: '', image: '', style: '' },
};
diff --git a/src/components/profile-components/order-status/order-status.module.scss b/src/components/profile-components/order-status/order-status.module.scss
index cce1c642..6310459a 100644
--- a/src/components/profile-components/order-status/order-status.module.scss
+++ b/src/components/profile-components/order-status/order-status.module.scss
@@ -36,6 +36,10 @@
border: 1px solid $tip-color;
}
+.checked {
+ border: 1px solid $green-primary-700;
+}
+
.icon {
line-height: 0;
width: 24px;
diff --git a/src/components/profile-components/profile-order-mobile/index.tsx b/src/components/profile-components/profile-order-mobile/index.tsx
index ddf2a7a2..b1aca348 100644
--- a/src/components/profile-components/profile-order-mobile/index.tsx
+++ b/src/components/profile-components/profile-order-mobile/index.tsx
@@ -1,42 +1,10 @@
+import clsx from 'clsx';
import ProductCard from '@components/product-card';
-import styles from './profile-order-mobile.module.scss';
+import PaymentButton from '@components/payment-button';
import OrderStatus from '../order-status';
-import clsx from 'clsx';
-// import { OrderList } from '@services/generated-api/data-contracts';
-
-type OrderStatusType =
- | 'Ordered'
- | 'In processing'
- | 'Collecting'
- | 'Gathered'
- | 'In delivering'
- | 'Delivered'
- | 'Completed';
-
-type Product = {
- amount: number;
- final_price: number;
- id: number;
- measure_unit: string;
- name: string;
- quantity: string;
- photo: string;
- category: {
- category_name: string;
- category_slug: string;
- };
-};
-
-type CommonOrder = {
- id: number;
- order_number?: string;
- ordering_date?: string;
- total_price?: string;
- payment_method?: string;
- delivery_method?: string;
- status?: OrderStatusType;
- products: Array<{ product: Product; quantity: string }> | Product[];
-};
+import type { CommonOrder, Product } from '@pages/profile/utils/types';
+import { getDeliveryMethodRu, getPaymentMethodRu } from '@pages/profile/utils/utils';
+import styles from './profile-order-mobile.module.scss';
type Props = {
readonly isShowedProductsDetails?: boolean;
@@ -59,19 +27,6 @@ const ProfileOrderMobile = ({
products,
} = order;
- let payment_method_ru =
- payment_method === 'Payment at the point of delivery'
- ? 'Банковской картой'
- : 'Наличные';
-
- let delivery_method_ru;
- if (delivery_method === 'Point of delivery') {
- delivery_method_ru = 'Самовывоз';
- } else {
- delivery_method_ru = 'Курьером';
- payment_method_ru += 'курьеру';
- }
-
const date = ordering_date && new Date(ordering_date).toLocaleDateString();
return (
{isShowedProductsDetails && (
- {(products as Array<{ product: Product; quantity: string }>).map((item) => (
+ {(products as Array<{ product: Product; quantity: number }>).map((item) => (
Способ получения:
- {delivery_method_ru}
+
+ {getDeliveryMethodRu(delivery_method)}
+
Способ оплаты:
- {payment_method_ru}
+
+ {getPaymentMethodRu(payment_method)}
+
{`${total_price} руб.`}
-
+ {order.is_paid || payment_method !== 'Online' ? (
+
+ ) : (
+
+ )}
);
diff --git a/src/components/profile-components/profile-order/index.tsx b/src/components/profile-components/profile-order/index.tsx
index 4e264a90..44af9dcf 100644
--- a/src/components/profile-components/profile-order/index.tsx
+++ b/src/components/profile-components/profile-order/index.tsx
@@ -1,40 +1,11 @@
-import styles from './profile-order.module.scss';
-import OrderStatus from '../order-status';
+import { Link } from 'react-router-dom';
import clsx from 'clsx';
-
-type OrderStatusType =
- | 'Ordered'
- | 'In processing'
- | 'Collecting'
- | 'Gathered'
- | 'In delivering'
- | 'Delivered'
- | 'Completed';
-
-type Product = {
- amount: number;
- final_price: number;
- id: number;
- measure_unit: string;
- name: string;
- quantity: string;
- photo: string;
- category: {
- category_name: string;
- category_slug: string;
- };
-};
-
-type CommonOrder = {
- id: number;
- order_number?: string;
- ordering_date?: string;
- total_price?: string;
- payment_method?: string;
- delivery_method?: string;
- status?: OrderStatusType;
- products: Array<{ product: Product; quantity: string }> | Product[];
-};
+import PaymentButton from '@components/payment-button';
+import OrderStatus from '../order-status';
+import { translateMeasureUnit } from '@utils/utils';
+import type { CommonOrder, Product } from '@pages/profile/utils/types';
+import { getDeliveryMethodRu, getPaymentMethodRu } from '@pages/profile/utils/utils';
+import styles from './profile-order.module.scss';
type Props = {
readonly isShowedProductsDetails?: boolean;
@@ -57,25 +28,20 @@ const ProfileOrder = ({
products,
} = order;
- console.log(status);
- let payment_method_ru =
- payment_method === 'Payment at the point of delivery'
- ? 'Банковской картой'
- : 'Наличные';
-
- let delivery_method_ru;
- if (delivery_method === 'Point of delivery') {
- delivery_method_ru = 'Самовывоз';
- } else {
- delivery_method_ru = 'Курьером';
- payment_method_ru += 'курьеру';
- }
+ const getAmountWithMeasureUnit = (
+ unitOfMeasure: string,
+ size: number,
+ multiplier: number
+ ) => {
+ const totalAmount = size * multiplier;
+ const { measureUnit, amount } = translateMeasureUnit(unitOfMeasure, totalAmount);
+ return `${amount} ${measureUnit}`;
+ };
const date = ordering_date && new Date(ordering_date).toLocaleDateString();
return (
<>
-
@@ -85,28 +51,42 @@ const ProfileOrder = ({
{isShowedProductsDetails ? (
- {(products as Array<{ product: Product; quantity: string }>).map((item) => (
+ {(products as Array<{ product: Product; quantity: number }>).map((item) => (
-
-
-
- {item.product.name}
- {`${
- item.product.amount * Number(item.quantity)
- } ${item.product.measure_unit}`}
- {`${item.product.final_price} руб.`}
+
+
+
+
+
+
+ {item.product.name}
+
+
+ {getAmountWithMeasureUnit(
+ item.product.measure_unit,
+ item.product.amount,
+ item.quantity
+ )}
+
+ {`${
+ item.product.final_price * item.quantity
+ } руб.`}
))}
) : (
- {(products as Array<{ product: Product; quantity: string }>).map(
+ {(products as Array<{ product: Product; quantity: number }>).map(
(item, index) => (
{index < 5 && (
@@ -126,14 +106,22 @@ const ProfileOrder = ({
-
{`Способ оплаты: ${payment_method_ru}`}
-
{`Способ получения: ${delivery_method_ru}`}
+
{`Оплата: ${getPaymentMethodRu(
+ payment_method
+ )}`}
+
{`Получение: ${getDeliveryMethodRu(
+ delivery_method
+ )}`}
-
+ {order.is_paid || payment_method !== 'Online' ? (
+
+ ) : (
+
+ )}
-
+
>
);
};
diff --git a/src/components/profile-components/profile-order/profile-order.module.scss b/src/components/profile-components/profile-order/profile-order.module.scss
index 2ffc34a8..4967cd15 100644
--- a/src/components/profile-components/profile-order/profile-order.module.scss
+++ b/src/components/profile-components/profile-order/profile-order.module.scss
@@ -56,7 +56,6 @@
&__name {
justify-self: start;
margin: 0;
- grid-column: 2;
text-align: left;
}
@@ -89,8 +88,19 @@
object-fit: cover;
width: 100%;
height: 100%;
+ }
+
+ &__linkImage {
+ cursor: pointer;
grid-column: 1;
}
+
+ &__linkText {
+ cursor: pointer;
+ text-decoration: none;
+ color: $active-text-color;
+ grid-column: 2;
+ }
}
.order-details {
diff --git a/src/components/ratings-and-reviews-components/rating-display/index.tsx b/src/components/ratings-and-reviews-components/rating-display/index.tsx
new file mode 100644
index 00000000..ed522fbf
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/rating-display/index.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { starsArray } from '../utils/constants';
+import { IRatingsAndReviews } from '../utils/types';
+import styles from './rating-display.module.scss';
+
+interface IRatingDisplay extends Pick {}
+
+const RatingDisplay: React.FC = ({ rating }) => {
+ return (
+
+ {starsArray.map((mark) => (
+
+ ))}
+
+ );
+};
+
+export default RatingDisplay;
diff --git a/src/components/ratings-and-reviews-components/rating-display/rating-display.module.scss b/src/components/ratings-and-reviews-components/rating-display/rating-display.module.scss
new file mode 100644
index 00000000..edb381b2
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/rating-display/rating-display.module.scss
@@ -0,0 +1,27 @@
+.container {
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+
+ @media screen and (width <= 768px) {
+ gap: 4px;
+ }
+}
+
+.star {
+ width: 24px;
+ aspect-ratio: 1;
+ background-image: url('@images/star-rating-outlined.svg');
+ background-position: center;
+ background-size: cover;
+
+ &.active {
+ background-image: url('@images/star-rating-filled.svg');
+ }
+
+ @media screen and (width <= 1180px) {
+ width: 12px;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/rating-input/index.tsx b/src/components/ratings-and-reviews-components/rating-input/index.tsx
new file mode 100644
index 00000000..5ef2a557
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/rating-input/index.tsx
@@ -0,0 +1,39 @@
+import React, { useState } from 'react';
+import { starsArray } from '../utils/constants';
+import { IRatingsAndReviews } from '../utils/types';
+import styles from './rating-input.module.scss';
+
+interface IRatingInput extends Pick {}
+
+const RatingInput: React.FC = ({ rating, onRatingChange }) => {
+ const [hover, setHover] = useState(0);
+
+ return (
+ setHover(0)}
+ >
+ {starsArray.map((mark) => (
+
+ onRatingChange(mark)}
+ checked={mark === rating}
+ />
+ setHover(mark)}
+ />
+
+ ))}
+
+ );
+};
+
+export default RatingInput;
diff --git a/src/components/ratings-and-reviews-components/rating-input/rating-input.module.scss b/src/components/ratings-and-reviews-components/rating-input/rating-input.module.scss
new file mode 100644
index 00000000..122c5e12
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/rating-input/rating-input.module.scss
@@ -0,0 +1,44 @@
+.container {
+ padding: 0;
+ margin: 0;
+ background: none;
+ border: none;
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+}
+
+.item {
+ padding: 0;
+ margin: 0;
+}
+
+.input {
+ display: none;
+}
+
+.star {
+ display: block;
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ aspect-ratio: 1;
+ background-image: url('@images/star-rating-outlined.svg');
+ background-color: transparent;
+ background-position: center;
+ background-size: cover;
+ border: none;
+
+ &.active,
+ &.hovered {
+ background-image: url('@images/star-rating-filled.svg');
+ }
+
+ .clickable {
+ cursor: pointer;
+ }
+
+ @media screen and (width <= 768px) {
+ width: 20px;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/ratings-and-reviews-widget/index.tsx b/src/components/ratings-and-reviews-components/ratings-and-reviews-widget/index.tsx
new file mode 100644
index 00000000..aaab677c
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/ratings-and-reviews-widget/index.tsx
@@ -0,0 +1,107 @@
+import React, { useEffect, useState } from 'react';
+import api from '@services/api';
+import ReviewAndRatingPostForm from '../review-and-rating-post-form';
+import RatingsBreakdown from '../ratings-breakdown';
+import ReviewsList from '../reviews-list';
+import {
+ IRatingsAndReviews,
+ IReview,
+ TRatingNullable,
+ TRatingsNullable,
+ TReviewNullable,
+ TReviewsNullable,
+} from '../utils/types';
+import styles from './ratings-and-reviews-widget.module.scss';
+
+interface IRatingsAndReviewsWidget extends Pick {}
+
+const RatingsAndReviewsWidget: React.FC = ({ productId }) => {
+ const [reviews, setReviews] = useState(null);
+ const [userReview, setUserReview] = useState(null);
+ const [reviewsWithText, setReviewsWithText] = useState(null);
+ const [ratings, setRatings] = useState(null);
+ const [hasOrderedThis, setHasOrderedThis] = useState(false);
+ const [userId, setUserId] = useState(null);
+
+ useEffect(() => {
+ api
+ .usersMeRead()
+ .then((res) => setUserId(res.id))
+ .catch((err) => console.log(err));
+ }, []);
+
+ useEffect(() => {
+ api
+ .reviewsList(productId)
+ .then((res) => {
+ res[0] &&
+ res.sort(function (a: IReview, b: IReview) {
+ return Number(new Date(b.pub_date)) - Number(new Date(a.pub_date));
+ });
+ setReviews(res[0] ? res : null);
+ })
+ .catch((err) => console.log(err));
+ api
+ .productsOrderCheck(productId)
+ .then((res) => setHasOrderedThis(res.ordered))
+ .catch((err) => console.log(err));
+ }, [productId]);
+
+ useEffect(() => {
+ if (!reviews) return;
+ const filtered = reviews.filter((review: IReview) => !!review.text);
+ const ratings = reviews.map((review: IReview) => review.score);
+ const userReview = reviews.find((review: IReview) => review.author.id === userId);
+ setReviewsWithText(filtered[0] ? filtered : null);
+ setRatings(ratings[0] ? ratings : null);
+ setUserReview(userReview || null);
+ }, [reviews, userId]);
+
+ const handleAddReview = (review: IReview) => {
+ setUserReview(review);
+ setReviews(reviews ? [review, ...reviews] : [review]);
+ };
+
+ const handleUpdateReview = (review: IReview) => {
+ setUserReview(review);
+ const newReviews = reviews
+ ?.slice()
+ .map((item) => (item.id === review.id ? review : item));
+ setReviews(newReviews || null);
+ };
+
+ return (
+ (hasOrderedThis || reviews) && (
+
+ Отзывы
+
+ {hasOrderedThis && (
+
+
+
+ )}
+ {ratings && (
+
+ {reviewsWithText && (
+
+
+
+ )}
+
+
+
+
+
+ )}
+
+
+ )
+ );
+};
+
+export default RatingsAndReviewsWidget;
diff --git a/src/components/ratings-and-reviews-components/ratings-and-reviews-widget/ratings-and-reviews-widget.module.scss b/src/components/ratings-and-reviews-components/ratings-and-reviews-widget/ratings-and-reviews-widget.module.scss
new file mode 100644
index 00000000..3759bb73
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/ratings-and-reviews-widget/ratings-and-reviews-widget.module.scss
@@ -0,0 +1,48 @@
+@use '@scss/_variables' as *;
+
+.section {
+ font-family: $ubuntu-font;
+ color: $active-text-color;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+
+ @media screen and (width <= 768px) {
+ gap: 20px;
+ }
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: 1fr 240px;
+ gap: 40px 107px;
+
+ @media screen and (width <= 1180px) {
+ grid-template-columns: 1fr 160px;
+ gap: 40px 40px;
+ }
+
+ @media screen and (width <= 768px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.form {
+ grid-column: span 2;
+
+ @media screen and (width <= 768px) {
+ grid-column: 1;
+ }
+}
+
+.ratings {
+ padding-right: 40px;
+
+ @media screen and (width <= 1180px) {
+ padding-right: 20px;
+ }
+
+ @media screen and (width <= 768px) {
+ grid-row: 1;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/ratings-breakdown/index.tsx b/src/components/ratings-and-reviews-components/ratings-breakdown/index.tsx
new file mode 100644
index 00000000..aebc4b3a
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/ratings-breakdown/index.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import plural from '../utils/pluralizer';
+import { starsArray, ratingsTitleOptions } from '../utils/constants';
+import { IRatingsAndReviews } from '../utils/types';
+import styles from './ratings-breakdown.module.scss';
+
+interface IRatingsBreakdown extends Pick {}
+
+const RatingsBreakdown: React.FC = ({ ratings }) => {
+ const sum = ratings.reduce((a, b) => a + b, 0);
+ const amount = ratings.length;
+ const average = (sum / amount).toFixed(1);
+ const title = ratingsTitleOptions[plural(amount)];
+
+ return (
+
+
+
+ {amount} {title}
+
+
+ {starsArray.map((mark) => (
+
+
+ element === mark).length || 0}
+ max={amount}
+ />
+
+ ))}
+
+
+ );
+};
+
+export default RatingsBreakdown;
diff --git a/src/components/ratings-and-reviews-components/ratings-breakdown/ratings-breakdown.module.scss b/src/components/ratings-and-reviews-components/ratings-breakdown/ratings-breakdown.module.scss
new file mode 100644
index 00000000..9c859110
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/ratings-breakdown/ratings-breakdown.module.scss
@@ -0,0 +1,102 @@
+@use '@scss/_variables' as *;
+
+.container {
+ font-family: $ubuntu-font;
+ color: $active-text-color;
+ display: flex;
+ flex-direction: column;
+}
+
+.average {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 4px;
+ font-size: 36px;
+ font-weight: 700;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 15px;
+ }
+}
+
+.bigStar {
+ width: 40px;
+ aspect-ratio: 1;
+ background-image: url('@images/star-rating-filled.svg');
+ background-position: center;
+ background-size: cover;
+
+ @media screen and (width <= 768px) {
+ width: 16px;
+ }
+}
+
+.smallStar {
+ width: 24px;
+ aspect-ratio: 1;
+ background-image: url('@images/star-rating-filled.svg');
+ background-position: center;
+ background-size: cover;
+
+ @media screen and (width <= 1180px) {
+ width: 16px;
+ }
+}
+
+.title {
+ padding: 0;
+ margin: 0;
+ font-size: 22px;
+ font-weight: 500;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 12px;
+ font-weight: 400;
+ }
+}
+
+.list {
+ padding: 20px 0 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 12px;
+}
+
+.item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 20px;
+}
+
+.mark {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ font-size: 22px;
+ font-weight: 500;
+ line-height: 140%;
+
+ @media screen and (width <= 1180px) {
+ font-size: 15px;
+ font-weight: 700;
+ }
+}
+
+.progress {
+ appearance: none;
+ height: 2px;
+
+ &::-webkit-progress-bar {
+ background-color: #293929;
+ }
+
+ &::-webkit-progress-value {
+ background-color: #f5b63b;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/review-and-rating-post-form/index.tsx b/src/components/ratings-and-reviews-components/review-and-rating-post-form/index.tsx
new file mode 100644
index 00000000..41ec3b5b
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/review-and-rating-post-form/index.tsx
@@ -0,0 +1,105 @@
+import React, { SyntheticEvent, useEffect } from 'react';
+import api from '@services/api';
+import { useFormAndValidation } from '@hooks/use-form-and-validation';
+import RatingInput from '@components/ratings-and-reviews-components/rating-input';
+import Button from '@components/Button';
+import { dateOptions } from '../utils/constants';
+import { IRatingsAndReviews, IReview, TRating, TReviewNullable } from '../utils/types';
+import styles from './review-and-rating-post-form.module.scss';
+
+interface IReviewAndRatingPostForm
+ extends Pick {
+ review: TReviewNullable;
+}
+
+const ReviewAndRatingPostForm: React.FC = ({
+ review,
+ productId,
+ onAddReview,
+ onUpdateReview,
+}) => {
+ const { values, setValues, handleChange } = useFormAndValidation({
+ text: review?.text || '',
+ score: review?.score || 0,
+ });
+
+ useEffect(() => {
+ review && setValues({ text: review?.text, score: review?.score });
+ }, [review, productId, setValues]);
+
+ const handleReviewUpdate = (res: IReview) => {
+ onUpdateReview(res);
+ return res;
+ };
+
+ const handleReviewCreate = (res: IReview) => {
+ onAddReview(res);
+ return res;
+ };
+
+ const handleReviewSubmit = (e: SyntheticEvent) => {
+ e.preventDefault();
+
+ // TODO: Add error processing in catch block
+ if (review?.score && values.text && values.text !== review?.text) {
+ api
+ .reviewsUpdate(productId, review.id, { text: values.text } as Pick<
+ IReview,
+ 'text'
+ >)
+ .then(handleReviewUpdate)
+ .catch((err) => console.log(err));
+ }
+ };
+
+ const handleRatingChange = (rating: TRating) => {
+ if (review?.score === rating) return;
+
+ const updateReview =
+ review && review.score > 0
+ ? api
+ .reviewsUpdate(productId, review.id, {
+ score: rating,
+ })
+ .then(handleReviewUpdate)
+ : api.reviewsCreate(productId, { score: rating }).then(handleReviewCreate);
+
+ // TODO: Add error processing in catch block
+ updateReview
+ .then(() => setValues({ ...values, score: rating }))
+ .catch((err) => console.log(err));
+ };
+
+ return (
+
+
+ {review ? 'Вы оценили товар ' : 'Оценить товар'}
+ {review && (
+
+ {new Date(review.pub_date).toLocaleDateString('ru-RU', dateOptions as never)}
+
+ )}
+
+
+
+
+ );
+};
+
+export default ReviewAndRatingPostForm;
diff --git a/src/components/ratings-and-reviews-components/review-and-rating-post-form/review-and-rating-post-form.module.scss b/src/components/ratings-and-reviews-components/review-and-rating-post-form/review-and-rating-post-form.module.scss
new file mode 100644
index 00000000..a445672b
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/review-and-rating-post-form/review-and-rating-post-form.module.scss
@@ -0,0 +1,39 @@
+@use '@scss/_variables' as *;
+
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ @media screen and (width <= 768px) {
+ gap: 12px;
+ }
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ @media screen and (width <= 768px) {
+ gap: 12px;
+ }
+}
+
+.input {
+ box-sizing: border-box;
+ padding: 20px;
+ min-height: 120px;
+ border-radius: 16px;
+ border: 1px solid $footer-background-color;
+ outline: none;
+ font-family: $ubuntu-font;
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 12px;
+ min-height: 104px;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/review/index.tsx b/src/components/ratings-and-reviews-components/review/index.tsx
new file mode 100644
index 00000000..34e383b5
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/review/index.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import RatingDisplay from '../rating-display';
+import { dateOptions } from '../utils/constants';
+import { IRatingsAndReviews } from '../utils/types';
+import styles from './review.module.scss';
+
+interface IReview extends Pick {}
+
+const Review: React.FC = ({ review }) => {
+ return (
+
+
+
+ {review.author.username}
+
+ {new Date(review.pub_date).toLocaleDateString('ru-RU', dateOptions as never)}
+
+
+
+
+ {review.text}
+
+ );
+};
+
+export default Review;
diff --git a/src/components/ratings-and-reviews-components/review/review.module.scss b/src/components/ratings-and-reviews-components/review/review.module.scss
new file mode 100644
index 00000000..e13ed40a
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/review/review.module.scss
@@ -0,0 +1,57 @@
+@use '@scss/_variables' as *;
+
+.container {
+ font-family: $ubuntu-font;
+ color: $active-text-color;
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+ box-sizing: border-box;
+ padding: 40px 40px 24px;
+ min-height: 178px;
+ border-radius: 16px;
+ border: 1px solid $footer-background-color;
+
+ @media screen and (width <= 768px) {
+ padding: 20px;
+ min-height: 104px;
+ gap: 12px;
+ }
+}
+
+.info {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.title {
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: row;
+ gap: 36px;
+ text-transform: uppercase;
+ font-size: 22px;
+ font-weight: 500;
+ line-height: 140%;
+
+ @media screen and (width <= 1180px) {
+ font-size: 14px;
+ gap: 22px;
+ }
+}
+
+.text {
+ padding: 0;
+ margin: 0;
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 140%;
+
+ @media screen and (width <= 1180px) {
+ font-size: 14px;
+ font-weight: 500;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/reviews-list/index.tsx b/src/components/ratings-and-reviews-components/reviews-list/index.tsx
new file mode 100644
index 00000000..3406d2c7
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/reviews-list/index.tsx
@@ -0,0 +1,39 @@
+import React, { useState } from 'react';
+import Review from '../review';
+import plural from '../utils/pluralizer';
+import { reviewsTitleOptions } from '../utils/constants';
+import { IRatingsAndReviews } from '../utils/types';
+import styles from './reviews-list.module.scss';
+
+interface IReviewsList extends Pick {}
+
+const ReviewsList: React.FC = ({ reviews }) => {
+ const [reviewsShown, setReviewsShown] = useState(3);
+ const amount = reviews.length;
+ const title = reviewsTitleOptions[plural(amount)];
+
+ return (
+
+
+ {amount} {title}
+
+
+ {reviews.slice(0, reviewsShown).map((review) => (
+
+
+
+ ))}
+
+ {reviewsShown < amount && (
+
setReviewsShown(reviewsShown + 3)}
+ >
+ Загрузить еще
+
+ )}
+
+ );
+};
+
+export default ReviewsList;
diff --git a/src/components/ratings-and-reviews-components/reviews-list/reviews-list.module.scss b/src/components/ratings-and-reviews-components/reviews-list/reviews-list.module.scss
new file mode 100644
index 00000000..5f553406
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/reviews-list/reviews-list.module.scss
@@ -0,0 +1,52 @@
+@use '@scss/_variables' as *;
+@use '@scss/_mixins' as *;
+
+.container {
+ font-family: $ubuntu-font;
+ color: $active-text-color;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.title {
+ padding: 0;
+ margin: 0;
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 15px;
+ }
+}
+
+.list {
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.button {
+ @extend %default-button-text;
+
+ align-self: start;
+ padding: 10px 20px;
+ background-color: transparent;
+ border: 1px solid $accent-color-darkgreen;
+ border-radius: 16px;
+ transition: 0.3s;
+
+ &:hover {
+ color: white;
+ background-color: $accent-color-bright-green;
+ }
+
+ @media screen and (width <= 768px) {
+ font-size: 13px;
+ padding: 8px 16px;
+ border-radius: 12px;
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/utils/constants.ts b/src/components/ratings-and-reviews-components/utils/constants.ts
new file mode 100644
index 00000000..7d321c96
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/utils/constants.ts
@@ -0,0 +1,11 @@
+// Possible ratings
+export const starsArray = [1, 2, 3, 4, 5];
+
+// Ratings breakdown component title options for different plurals
+export const ratingsTitleOptions = ['оценок', 'оценка', 'оценки'];
+
+// Reviews list component title options for different plurals
+export const reviewsTitleOptions = ['отзывов', 'отзыв', 'отзыва'];
+
+// Date formatting options
+export const dateOptions = { day: '2-digit', month: '2-digit', year: '2-digit' };
diff --git a/src/components/ratings-and-reviews-components/utils/pluralizer.ts b/src/components/ratings-and-reviews-components/utils/pluralizer.ts
new file mode 100644
index 00000000..69a6f302
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/utils/pluralizer.ts
@@ -0,0 +1,23 @@
+// NOTE: This is used to pick a value from array of word forms
+// For English, 1st and 3rd elements should be the same
+
+// Example: ['дней', 'день', 'дня'][plural(x)]
+// 0 -> 'дней'
+// 1 -> 'день'
+// 2 -> 'дня'
+// 3 -> 'дня'
+// 231 -> 'день'
+
+export default function plural(x: number) {
+ if (x % 100 >= 5 && x % 100 <= 19) {
+ return 0;
+ } else {
+ if (x % 10 === 0 || x % 10 >= 5) {
+ return 0;
+ } else if (x % 10 >= 2 && x % 10 <= 4) {
+ return 2;
+ } else {
+ return 1;
+ }
+ }
+}
diff --git a/src/components/ratings-and-reviews-components/utils/types.ts b/src/components/ratings-and-reviews-components/utils/types.ts
new file mode 100644
index 00000000..1f310c43
--- /dev/null
+++ b/src/components/ratings-and-reviews-components/utils/types.ts
@@ -0,0 +1,24 @@
+import { Review } from '@services/generated-api/data-contracts';
+
+export type TRating = number;
+
+export interface IRatingsAndReviews {
+ productId: number;
+ rating: TRating;
+ ratings: TRating[];
+ review: Review;
+ reviews: Review[];
+ onRatingChange: (rating: TRating) => void;
+ onAddReview: (review: Review) => void;
+ onUpdateReview: (review: Review) => void;
+}
+
+export interface IReview extends Review {}
+
+export type TReviewsNullable = null | Review[];
+
+export type TReviewNullable = null | Review;
+
+export type TRatingsNullable = null | number[];
+
+export type TRatingNullable = null | number;
diff --git a/src/components/recipes-components/ingredients-list-popup/index.tsx b/src/components/recipes-components/ingredients-list-popup/index.tsx
index 2106822f..9d346363 100644
--- a/src/components/recipes-components/ingredients-list-popup/index.tsx
+++ b/src/components/recipes-components/ingredients-list-popup/index.tsx
@@ -1,18 +1,11 @@
import React from 'react';
+import type { RecipeIngredientsProps } from '../types';
import styles from './ingredients-list-popup.module.scss';
-type RecipeIngredientsProps = {
- ingredients: {
- id: number;
- name: string;
- measure_unit: string;
- quantity: number;
- photo?: string;
- amount?: number;
- }[];
-};
-
-const IngredientsListPopup: React.FC = ({ ingredients }) => {
+const IngredientsListPopup: React.FC = ({
+ ingredients,
+ handleClick,
+}) => {
return (
Из рецепта:
@@ -20,12 +13,15 @@ const IngredientsListPopup: React.FC
= ({ ingredients })
{ingredients?.map((ingredient, index) => {
return (
-
+ handleClick(ingredient.id)}
+ className={styles['popup-ingredients__name']}
+ >
{ingredient?.name}
- {`${ingredient?.quantity} ${ingredient?.measure_unit}`}
+
+ {ingredient.quantity_in_recipe_measure}
+
);
})}
diff --git a/src/components/recipes-components/ingredients-list-popup/ingredients-list-popup.module.scss b/src/components/recipes-components/ingredients-list-popup/ingredients-list-popup.module.scss
index 3d90ea41..651ff19a 100644
--- a/src/components/recipes-components/ingredients-list-popup/ingredients-list-popup.module.scss
+++ b/src/components/recipes-components/ingredients-list-popup/ingredients-list-popup.module.scss
@@ -36,6 +36,7 @@
&__name {
font-size: 20px;
line-height: 140%;
+ cursor: pointer;
}
&__weight {
diff --git a/src/components/recipes-components/ingredients-list/index.tsx b/src/components/recipes-components/ingredients-list/index.tsx
index 430f7998..6210a0ee 100644
--- a/src/components/recipes-components/ingredients-list/index.tsx
+++ b/src/components/recipes-components/ingredients-list/index.tsx
@@ -1,21 +1,13 @@
import React from 'react';
+import clsx from 'clsx';
+import type { RecipeIngredientsProps } from '../types';
import { declOfNum } from '@utils/utils';
import styles from './ingredients-list.module.scss';
-import clsx from 'clsx';
-
-type RecipeIngredientsProps = {
- ingredients: {
- id: number;
- name: string;
- measure_unit: string;
- quantity: number;
- ingredient_photo?: string;
- amount?: number;
- price?: number;
- }[];
-};
-const IngredientsList: React.FC = ({ ingredients }) => {
+const IngredientsList: React.FC = ({
+ ingredients,
+ handleClick,
+}) => {
const numOfIngredients = ingredients.length;
const numeralizeWord = declOfNum(numOfIngredients, [
'ингредиент',
@@ -29,29 +21,24 @@ const IngredientsList: React.FC = ({ ingredients }) => {
{`${ingredients.length} ${numeralizeWord}`}
- {ingredients.map((ingredient, index) => {
- if (ingredient.measure_unit === 'items') {
- ingredient.measure_unit = 'шт';
- } else if (ingredient.measure_unit === 'grams') {
- ingredient.measure_unit = 'гр';
- } else if (ingredient.measure_unit === 'milliliters') {
- ingredient.measure_unit = 'мл';
- }
- return (
-
-
-
-
-
{ingredient.name}
-
{`${ingredient?.quantity} ${ingredient.measure_unit}`}
+ {ingredients.map((ingredient, index) => (
+
+
+
handleClick(ingredient.id)}
+ />
- );
- })}
+
handleClick(ingredient.id)}
+ className={styles.ingredient__name}
+ >{`${ingredient.name}, ${ingredient.amount + ingredient.measure_unit}`}
+
+ {ingredient.quantity_in_recipe_measure}
+
+
+ ))}
);
diff --git a/src/components/recipes-components/ingredients-list/ingredients-list.module.scss b/src/components/recipes-components/ingredients-list/ingredients-list.module.scss
index bf5dd705..33a4f9b8 100644
--- a/src/components/recipes-components/ingredients-list/ingredients-list.module.scss
+++ b/src/components/recipes-components/ingredients-list/ingredients-list.module.scss
@@ -38,6 +38,7 @@
height: 74px;
overflow: hidden;
border-radius: 16px;
+ cursor: pointer;
& > img {
width: 100%;
@@ -50,6 +51,7 @@
font-size: 20px;
line-height: 140%;
margin: 0;
+ cursor: pointer;
}
&__weight {
diff --git a/src/components/recipes-components/products-list-popup/index.tsx b/src/components/recipes-components/products-list-popup/index.tsx
index be2af4c6..e3552094 100644
--- a/src/components/recipes-components/products-list-popup/index.tsx
+++ b/src/components/recipes-components/products-list-popup/index.tsx
@@ -1,38 +1,31 @@
-import React from 'react';
-import styles from './products-list-popup.module.scss';
+import React, { useState } from 'react';
import clsx from 'clsx';
-import { useState } from 'react';
+import { useCart } from '@hooks/use-cart-context';
+import type { ReceipeIngredient, RecipeIngredientsProps } from '../types';
import closeIcon from '@images/profile/close.svg';
import plusIcon from '@images/plus_button.svg';
import minusIcon from '@images/minus_button.svg';
+import styles from './products-list-popup.module.scss';
-type ReceipeIngredient = {
- id: number;
- name: string;
- measure_unit: string;
- quantity: number;
- photo?: string;
- amount?: number;
- price?: number;
-};
-
-type RecipeIngredientsProps = {
- ingredients: ReceipeIngredient[];
-};
-
-const ProductsListPopup: React.FC = ({ ingredients }) => {
+const ProductsListPopup: React.FC = ({
+ ingredients,
+ handleClick,
+}) => {
const [products, setProducts] = useState(Array);
+ const { updateCart, error, reset, successText, cartUpdating } = useCart();
const filterProducts = (index: number) => {
setProducts((prev) => prev.filter((_, i) => i !== index));
};
const changeAmount = (index: number) => {
+ reset();
+
return (newAmount: number) => {
setProducts(
products?.map((product, i) => {
if (i === index) {
- product.amount = Math.min(Math.max(newAmount, 1), 20);
+ product.need_to_buy = Math.min(Math.max(newAmount, 1), 20);
}
return product;
})
@@ -40,17 +33,19 @@ const ProductsListPopup: React.FC = ({ ingredients }) =>
};
};
+ const handleAddToCart = () => {
+ const data = products.map((prod) => {
+ return { id: prod.id, quantity: prod.need_to_buy };
+ });
+ updateCart(data);
+ };
+
React.useEffect(() => {
if (!ingredients) {
return;
}
- setProducts(
- ingredients.map((i) => {
- i.amount = 1;
- return i;
- })
- );
+ setProducts(ingredients);
}, [ingredients]);
return (
@@ -64,33 +59,40 @@ const ProductsListPopup: React.FC = ({ ingredients }) =>
key={product.name}
>
-
+
handleClick(product.id)}
+ src={product.ingredient_photo}
+ alt={product.name}
+ />
- {product.name}
+ handleClick(product.id)}
+ >{`${product.name}, ${product.amount}${product.measure_unit}`}
changeAmount(index)(product.amount - 1)}
+ onClick={() =>
+ product.need_to_buy && changeAmount(index)(product.need_to_buy - 1)
+ }
>
-
{`${product.amount} уп.`}
+
{product.need_to_buy}
changeAmount(index)(product.amount + 1)}
+ onClick={() =>
+ product.need_to_buy && changeAmount(index)(product.need_to_buy + 1)
+ }
>
- {product.price && product.amount && (
+ {product.final_price && product.need_to_buy && (
{`${
- product.price * product.amount
+ product.final_price * product.need_to_buy
} руб.`}
)}
= ({ ingredients }) =>
))}
- Добавить в корзину
+
+ {successText.updateCart || error.updateCart}
+
+
+ Добавить в корзину
+
)
);
diff --git a/src/components/recipes-components/products-list-popup/products-list-popup.module.scss b/src/components/recipes-components/products-list-popup/products-list-popup.module.scss
index eaa76c03..06056cde 100644
--- a/src/components/recipes-components/products-list-popup/products-list-popup.module.scss
+++ b/src/components/recipes-components/products-list-popup/products-list-popup.module.scss
@@ -36,7 +36,18 @@
line-height: 1.4;
cursor: pointer;
color: #fff;
- background: $form-button-color;
+ background-color: $form-button-color;
+ transition:
+ background-color 0.2s ease-in-out,
+ color 0.2s ease-in-out;
+ }
+
+ &__button:disabled {
+ background-color: $gray-button;
+ color: $footer-background-color;
+ transition:
+ background-color 0.2s ease-in-out,
+ color 0.2s ease-in-out;
}
}
@@ -51,6 +62,7 @@
height: 74px;
border-radius: 16px;
overflow: hidden;
+ cursor: pointer;
& > img {
width: 100%;
@@ -63,6 +75,7 @@
font-size: 20px;
line-height: 140%;
margin: 0;
+ cursor: pointer;
}
&__price {
@@ -75,6 +88,25 @@
background-color: transparent;
border: none;
}
+
+ &__resultText {
+ color: $error-color;
+ font-size: 13px;
+ font-weight: 300;
+ line-height: 140%;
+ min-height: 30px;
+ margin: 0;
+ }
+
+ &__error {
+ color: $error-color;
+ font-weight: 300;
+ }
+
+ &__success {
+ color: $accent-color-bright-green;
+ font-weight: 400;
+ }
}
.counter {
diff --git a/src/components/recipes-components/recipe-info/index.tsx b/src/components/recipes-components/recipe-info/index.tsx
index e41ff970..9a9617f9 100644
--- a/src/components/recipes-components/recipe-info/index.tsx
+++ b/src/components/recipes-components/recipe-info/index.tsx
@@ -4,7 +4,8 @@ import { useParams } from 'react-router';
type RecipeInfoProps = {
img: string;
- recipe_nutrients?: {
+ description: string;
+ recipeNutrients: {
proteins: number;
fats: number;
carbonhydrates: number;
@@ -12,15 +13,16 @@ type RecipeInfoProps = {
};
};
-const RecipeInfo: React.FC = ({ img, recipe_nutrients }) => {
+const RecipeInfo: React.FC = ({ img, description, recipeNutrients }) => {
const { id } = useParams();
return (
+
{description}
Энергетическая ценность на порцию
-
+
);
};
diff --git a/src/components/recipes-components/recipe-info/recipe-info.module.scss b/src/components/recipes-components/recipe-info/recipe-info.module.scss
index cf3f408b..90772092 100644
--- a/src/components/recipes-components/recipe-info/recipe-info.module.scss
+++ b/src/components/recipes-components/recipe-info/recipe-info.module.scss
@@ -18,13 +18,6 @@
}
}
- &__description {
- margin: 0;
- font-size: 20px;
- line-height: 140%;
- margin-bottom: 20px;
- }
-
&__info {
margin: 0;
font-size: 22px;
@@ -32,4 +25,12 @@
line-height: 140%;
margin-bottom: 8px;
}
+
+ &__description {
+ margin: 0;
+ padding-top: 16px;
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 140%;
+ }
}
diff --git a/src/components/recipes-components/types.ts b/src/components/recipes-components/types.ts
new file mode 100644
index 00000000..317190be
--- /dev/null
+++ b/src/components/recipes-components/types.ts
@@ -0,0 +1,31 @@
+export type ReceipeIngredient = {
+ amount: number;
+ final_price: number;
+ id: number;
+ ingredient_photo: string;
+ measure_unit: string;
+ name: string;
+ need_to_buy: number;
+ quantity_in_recipe: number;
+ quantity_in_recipe_measure?: string;
+};
+
+export type RecipeIngredientsProps = {
+ ingredients: ReceipeIngredient[];
+ handleClick: (id: number, idAndCategories?: (string | number | undefined)[][]) => void;
+};
+
+export type ReceipeInfoProps = {
+ author: number;
+ carbohydrates: number;
+ cooking_time: number;
+ fats: number;
+ id: number;
+ image: string;
+ ingredients: ReceipeIngredient[];
+ kcal: number;
+ name: string;
+ proteins: number;
+ text: string;
+ total_ingredients?: number;
+};
diff --git a/src/components/scroll-to-anchor-hash/index.tsx b/src/components/scroll-to-anchor-hash/index.tsx
new file mode 100644
index 00000000..8e9824ed
--- /dev/null
+++ b/src/components/scroll-to-anchor-hash/index.tsx
@@ -0,0 +1,26 @@
+import { useEffect, useRef } from 'react';
+import { useLocation } from 'react-router-dom';
+
+function ScrollToAnchorHash() {
+ const location = useLocation();
+ const lastHash = useRef('');
+
+ useEffect(() => {
+ if (location.hash) {
+ lastHash.current = location.hash.slice(1);
+ }
+
+ if (lastHash.current && document.getElementById(lastHash.current)) {
+ setTimeout(() => {
+ document
+ .getElementById(lastHash.current)
+ ?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ lastHash.current = '';
+ }, 100);
+ }
+ }, [location]);
+
+ return null;
+}
+
+export default ScrollToAnchorHash;
diff --git a/src/components/shopping-item/index.tsx b/src/components/shopping-item/index.tsx
index 62b73959..e759faa2 100644
--- a/src/components/shopping-item/index.tsx
+++ b/src/components/shopping-item/index.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import styles from './shopping-item.module.scss';
import { Link } from 'react-router-dom';
import { useCart } from '@hooks/use-cart-context.ts';
+import { translateMeasureUnit } from '@utils/utils';
type ShoppingItemProps = {
product: {
@@ -12,12 +13,18 @@ type ShoppingItemProps = {
quantity: number;
final_price: number;
total_price: number;
+ amount: number;
+ measure_unit: string;
};
};
const ShoppingItem: React.FC = (props) => {
const { addItemToCart, removeItemFromCart, deleteCart } = useCart();
const { product } = props;
+ const { measureUnit, amount } = translateMeasureUnit(
+ product.measure_unit,
+ product.amount
+ );
const handleDeleteClick = () => {
deleteCart(product.id);
@@ -43,16 +50,21 @@ const ShoppingItem: React.FC = (props) => {
-
{product.name}
+
+
{`${product.name}, ${
+ amount + measureUnit
+ }`}
+
-
{`${product.quantity} шт`}
+
{product.quantity}
= ({ title, link, type }) => {
+ return (
+
+
+ {title}
+
+
+
+ );
+};
+
+export default TitleArrowLink;
diff --git a/src/components/title-arrow-link/title-arrow-link.module.scss b/src/components/title-arrow-link/title-arrow-link.module.scss
new file mode 100644
index 00000000..616ae385
--- /dev/null
+++ b/src/components/title-arrow-link/title-arrow-link.module.scss
@@ -0,0 +1,59 @@
+@use '@scss/variables' as *;
+
+.link__title {
+ color: $active-text-color;
+ font-family: $ubuntu-font;
+ font-size: 30px;
+ font-weight: 700;
+ line-height: 140%;
+ margin: 0;
+ padding-right: 5px;
+
+ @media screen and (width <= 768px) {
+ font-size: 24px;
+ padding-right: 3px;
+ }
+}
+
+.link__title_type_smallFont {
+ @media screen and (width <= 768px) {
+ font-size: 18px;
+ }
+}
+
+.link__arrow {
+ width: 20px;
+ height: 32px;
+ margin-top: auto;
+ background-position: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ stroke: $active-text-color;
+
+ @media screen and (width <= 768px) {
+ width: 18px;
+ height: 28px;
+ }
+}
+
+.link__arrow_type_small {
+ @media screen and (width <= 768px) {
+ width: 14px;
+ height: 20px;
+ }
+}
+
+.link {
+ text-decoration: none;
+ display: flex;
+ max-width: max-content;
+
+ &:hover .link__title,
+ &:hover .link__arrow {
+ stroke: $accent-color-bright-green;
+ color: $accent-color-bright-green;
+ transition:
+ color 0.2s ease-in-out,
+ stroke 0.2s ease-in-out;
+ }
+}
diff --git a/src/components/top-selling-this-week/index.tsx b/src/components/top-selling-this-week/index.tsx
index e3507cc8..c0479597 100644
--- a/src/components/top-selling-this-week/index.tsx
+++ b/src/components/top-selling-this-week/index.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import api from '@services/api';
import type { Product } from '@services/generated-api/data-contracts';
-import Button from '@components/Button';
+import Button from '@components/button';
import ProductCard from '@components/product-card';
import styles from './top-selling-this-week.module.scss';
@@ -62,7 +62,7 @@ const TopSellingThisWeek: React.FC = () => {
findTopThreeProducts();
}
if (buttonId === 2) {
- const slugs = ['fruits', 'vegetables'];
+ const slugs = ['fruits', 'vegetables-and-herbs'];
findTopThreeProducts(slugs);
}
if (buttonId === 3) {
diff --git a/src/contexts/cart-context.tsx b/src/contexts/cart-context.tsx
index e4c50184..a765c477 100644
--- a/src/contexts/cart-context.tsx
+++ b/src/contexts/cart-context.tsx
@@ -31,12 +31,24 @@ type CartDataItem = {
total_price: number;
};
+type ResponseText = {
+ loadCartData: string;
+ updateCart: string;
+ deleteCart: string;
+ clearCart: string;
+};
+
type CartContextType = {
cartData: CartDataItem;
loading: boolean;
+ cartUpdating: boolean;
+ error: ResponseText;
+ successText: ResponseText;
+ reset: () => void;
loadCartData: () => void;
- updateCart: (id: number, quantity: number) => void;
+ updateCart: (data: Product[]) => void;
deleteCart: (id: number) => void;
+ clearCart: () => void;
addItemToCart: (id: number) => void;
removeItemFromCart: (id: number) => void;
};
@@ -48,9 +60,24 @@ const CartContext = createContext({
total_price: 0,
},
loading: true,
+ error: {
+ loadCartData: '',
+ updateCart: '',
+ deleteCart: '',
+ clearCart: '',
+ },
+ successText: {
+ loadCartData: '',
+ updateCart: '',
+ deleteCart: '',
+ clearCart: '',
+ },
+ cartUpdating: false,
+ reset: () => {},
loadCartData: () => {},
updateCart: () => {},
deleteCart: () => {},
+ clearCart: () => {},
addItemToCart: () => {},
removeItemFromCart: () => {},
});
@@ -63,17 +90,35 @@ export const CartProvider: React.FC = ({ children }) => {
});
const [cartUpdating, setCartUpdating] = useState(false);
const [loading, setLoading] = useState(true);
+ const [successText, setSuccessText] = useState({
+ loadCartData: '',
+ updateCart: '',
+ deleteCart: '',
+ clearCart: '',
+ });
+ const [error, setError] = useState({
+ loadCartData: '',
+ updateCart: '',
+ deleteCart: '',
+ clearCart: '',
+ });
const loadCartData = () => {
+ setCartUpdating(true);
+ setLoading(true);
+
api
.usersShoppingCartList()
.then((data) => {
setCartData(data);
- setCartUpdating(true);
- setLoading(true);
})
.catch((error) => {
- console.error('Error loading cart data:', error);
+ setError((prev) => {
+ return {
+ ...prev,
+ loadCartData: error.errors?.[0]?.detail || 'Ошибка загрузки корзины покупок',
+ };
+ });
})
.finally(() => {
setCartUpdating(false);
@@ -81,24 +126,28 @@ export const CartProvider: React.FC = ({ children }) => {
});
};
- const updateCart = (idProduct: number, quantityProduct: number) => {
+ const updateCart = (data: Product[]) => {
+ reset();
const updatedCartItem: ShoppingCartItem = {
- products: [
- {
- id: idProduct,
- quantity: quantityProduct,
- },
- ],
+ products: data,
};
+ setCartUpdating(true);
api
.usersShoppingCartCreate(updatedCartItem)
.then((data) => {
setCartData(data);
- setCartUpdating(true);
+ setSuccessText((prev) => {
+ return { ...prev, updateCart: 'Продукты успешно добавлены в корзину' };
+ });
})
.catch((error) => {
- console.error('Error updating cart:', error);
+ setError((prev) => {
+ return {
+ ...prev,
+ updateCart: error.errors?.[0]?.detail || 'Ошибка обновления корзины покупок',
+ };
+ });
})
.finally(() => {
setCartUpdating(false);
@@ -108,17 +157,43 @@ export const CartProvider: React.FC = ({ children }) => {
const deleteCart = (idProduct: number) => {
api
.usersShoppingCartDelete(idProduct)
+ .catch((error) => {
+ if (error?.errors) {
+ setError((prev) => {
+ return {
+ ...prev,
+ deleteCart:
+ error.errors[0]?.detail || 'Ошибка удаления товара из корзины покупок',
+ };
+ });
+ }
+ })
.finally(() => {
loadCartData();
+ });
+ };
+
+ const clearCart = () => {
+ api
+ .usersShoppingCartDeleteAll()
+ .then(({ message }) => {
+ loadCartData();
+ setSuccessText((prev) => {
+ return { ...prev, clearCart: message };
+ });
})
.catch((error) => {
- console.error('Error deleting cart item:', error);
+ setError((prev) => {
+ return {
+ ...prev,
+ clearCart: error.errors || 'Ошибка очистки корзины покупок',
+ };
+ });
});
};
const addItemToCart = (productId: number) => {
if (!cartUpdating) {
- console.log(cartUpdating);
const existingProductIndex = cartData.products.findIndex(
(product) => product.id === productId
);
@@ -126,7 +201,12 @@ export const CartProvider: React.FC = ({ children }) => {
if (existingProductIndex !== -1) {
if (cartData.products[existingProductIndex].quantity < 10) {
cartData.products[existingProductIndex].quantity += 1;
- updateCart(productId, cartData.products[existingProductIndex]?.quantity);
+ updateCart([
+ {
+ id: productId,
+ quantity: cartData.products[existingProductIndex]?.quantity,
+ },
+ ]);
} else {
console.error('Превышено максимальное количество товара в корзине');
}
@@ -136,6 +216,15 @@ export const CartProvider: React.FC = ({ children }) => {
}
};
+ const reset = () => {
+ setError((prev) => {
+ return { ...prev, updateCart: '' };
+ });
+ setSuccessText((prev) => {
+ return { ...prev, updateCart: '' };
+ });
+ };
+
const removeItemFromCart = (productId: number) => {
if (!cartUpdating) {
const existingItemIndex = cartData.products.findIndex(
@@ -149,7 +238,7 @@ export const CartProvider: React.FC = ({ children }) => {
existingItem.quantity -= 1;
}
- updateCart(productId, existingItem?.quantity || 0);
+ updateCart([{ id: productId, quantity: existingItem?.quantity || 0 }]);
}
} else {
console.log('Корзина обновляется. Пожалуйста, подождите.');
@@ -165,9 +254,14 @@ export const CartProvider: React.FC = ({ children }) => {
value={{
cartData,
loading,
+ error,
+ successText,
+ cartUpdating,
+ reset,
loadCartData,
updateCart,
deleteCart,
+ clearCart,
addItemToCart,
removeItemFromCart,
}}
diff --git a/src/contexts/popup-context.tsx b/src/contexts/popup-context.tsx
index a57bf205..c3eed1c6 100644
--- a/src/contexts/popup-context.tsx
+++ b/src/contexts/popup-context.tsx
@@ -8,6 +8,7 @@ type PopupState = {
openPopupAddressesWarning: boolean;
openPopupAddressesDeleteConfirm: boolean;
openPopupRecipe: boolean;
+ openPopupCheckoutResponse: boolean;
};
type PopupContextType = {
@@ -27,6 +28,7 @@ export const PopupProvider: React.FC<{ children: ReactNode }> = ({ children }) =
openPopupAddressesWarning: false,
openPopupAddressesDeleteConfirm: false,
openPopupRecipe: false,
+ openPopupCheckoutResponse: false,
});
const handleOpenPopup = (popupName: string) => {
diff --git a/src/data/constants.ts b/src/data/constants.ts
index 18f2f764..986682c6 100644
--- a/src/data/constants.ts
+++ b/src/data/constants.ts
@@ -6,4 +6,41 @@ export const URLS = {
PROFILE: '/profile',
CATALOG: '/catalog',
AGREEMENT: '/agreement',
+ DELIVERY_COND: '/delivery-conditions',
+ CART_SUCCESS: '/cart/success',
+ PROFILE_ORDERS: '/profile/orders/',
};
+
+export const pickupPointAddresses = {
+ 1: 'Санкт-Петербург Невский проспект 17',
+ 2: 'Санкт-Петербург Горохова улица 10',
+ 3: 'Санкт-Петербург Невский проспект 89',
+ 4: 'Санкт-Петербург Большой Самсониевский 6',
+ 5: 'Санкт-Петербург Лесной проспект 56',
+ 6: 'Санкт-Петербург Владимирский проспект 1',
+ 7: 'Санкт-Петербург Лиговский проспект 170',
+};
+
+export const popupInfoText = {
+ fillNameAuth: 'Пожалуйста, заполните имя в личном кабинете',
+ fillSurnameAuth: 'Пожалуйста, заполните фамилию в личном кабинете',
+ fillPhoneAuth: 'Пожалуйста, заполните номер телефона в личном кабинете',
+ chooseAddress: 'Пожалуйста, выберите адрес',
+ chooseOrFillAddress:
+ 'Пожалуйста, выберите адрес (добавить адрес можно в личном кабинете)',
+ choosePaymentMethod: 'Пожалуйста, выберите способ оплаты',
+ enterSurname: 'Пожалуйста, заполните фамилию',
+ enterName: 'Пожалуйста, заполните имя',
+ enterPhone: 'Пожалуйста, заполните номер телефона',
+ enterEmail: 'Пожалуйста, заполните e-mail',
+ enterAddress: 'Пожалуйста, введите адрес',
+ errorShort: 'Ошибка при создании заказа: ',
+ errorLong: 'Ошибка при создании заказа: ',
+ selectAgreement:
+ 'Для оформления заказа необходимо согласие с условиями обработки персональных данных и условиями продажи.',
+};
+
+export const textIfOrderPaid =
+ 'Пока вы ждёте заказ, можете ознакомиться с рецептами из нашего блога';
+export const textIfOrderNotPaid =
+ 'Пока решается проблема с оплатой, вы можете ознакомиться с рецептами из нашего блога';
diff --git a/src/data/dataExamples.ts b/src/data/dataExamples.ts
index 6adac500..b4ffaa99 100644
--- a/src/data/dataExamples.ts
+++ b/src/data/dataExamples.ts
@@ -1,38 +1,5 @@
import TomatoesForProductPage from '@images/tomatoes_for_product_page.png';
-export const mainPageBlockLinks = [
- {
- title: 'Овощи',
- link: 'vegetables-and-herbs',
- backgroundImage: '/383bea75365411fd84f9c6c47e081cab.jpeg',
- gridArea: 'a',
- },
- {
- title: 'Фрукты',
- link: 'fruits',
- backgroundImage: '/e8ab3ec81c9d3e4cc473a3d6ae86bc5a.jpeg',
- gridArea: 'b',
- },
- {
- title: 'Орехи',
- link: 'nuts-dried-fruits',
- backgroundImage: '/05c2db3efd894fa03b952abf2d5a88ee.jpeg',
- gridArea: 'c',
- },
- {
- title: 'Молочные продукты',
- link: 'dairy',
- backgroundImage: '/5b5f8ca8a8f4f583ac88b6e80a646e10.jpeg',
- gridArea: 'd',
- },
- {
- title: 'Мясо и птица',
- link: 'meat-and-poultry',
- backgroundImage: '/4e039f6d2c33797f4fd913bd642549f0.jpeg',
- gridArea: 'e',
- },
-];
-
export const products = [
{
cardName: 'Помидоры черри',
diff --git a/src/layouts/footer/index.tsx b/src/layouts/footer/index.tsx
index feff2e29..cbee4cc7 100644
--- a/src/layouts/footer/index.tsx
+++ b/src/layouts/footer/index.tsx
@@ -19,7 +19,9 @@ const Footer: React.FC = () => {
О нас
- Условия доставки
+
+ Условия доставки
+
Оплата
Контакты
diff --git a/src/layouts/header/header.module.scss b/src/layouts/header/header.module.scss
index 4b269fd1..61cd2c94 100644
--- a/src/layouts/header/header.module.scss
+++ b/src/layouts/header/header.module.scss
@@ -3,11 +3,16 @@
@use '@scss/base.scss' as *;
.header {
- position: relative;
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: #fff;
width: 100%;
+ max-width: 1280px;
padding: 24px 0;
box-shadow: 2px 2px 20px 0 rgb(0 0 0 / 10%);
- z-index: 2;
+ z-index: 100;
}
.header__container {
diff --git a/src/layouts/header/index.tsx b/src/layouts/header/index.tsx
index 83a8924c..c3917524 100644
--- a/src/layouts/header/index.tsx
+++ b/src/layouts/header/index.tsx
@@ -37,7 +37,7 @@ const Header: React.FC = () => {
-
+
);
diff --git a/src/layouts/layout/layout.module.scss b/src/layouts/layout/layout.module.scss
index 8485c1dd..8391345f 100644
--- a/src/layouts/layout/layout.module.scss
+++ b/src/layouts/layout/layout.module.scss
@@ -11,4 +11,9 @@
.layout__main {
flex: 1;
min-height: 77vh;
+ padding-top: 100px;
+
+ @media screen and (width < 1024px) {
+ padding-top: 80px;
+ }
}
diff --git a/src/layouts/layout/layout.tsx b/src/layouts/layout/layout.tsx
index 50121d18..87c85325 100644
--- a/src/layouts/layout/layout.tsx
+++ b/src/layouts/layout/layout.tsx
@@ -1,4 +1,5 @@
-import React, { ReactNode } from 'react';
+import React, { ReactNode, useEffect } from 'react';
+import { useLocation } from 'react-router';
import Header from '../header';
import Footer from '../footer';
import style from './layout.module.scss';
@@ -8,6 +9,12 @@ type LayoutProps = {
};
const Layout: React.FC = ({ children }) => {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname]);
+
return (
diff --git a/src/pages/agreement/agreement.module.scss b/src/pages/agreement/agreement.module.scss
index 4008805f..9246e1b4 100644
--- a/src/pages/agreement/agreement.module.scss
+++ b/src/pages/agreement/agreement.module.scss
@@ -17,18 +17,24 @@
}
.agreement_title {
- margin: 80px 0 40px;
+ margin: 80px 0 0;
@media screen and (width <= 768px) {
- margin: 20px 0;
+ margin: 20px 0 0;
font-size: 24px;
}
}
.agreement_subtitle {
+ margin-top: 40px;
text-align: start;
@media screen and (width <= 768px) {
+ margin-top: 30px;
font-size: 15px;
}
}
+
+.agreement_p {
+ width: 100%;
+}
diff --git a/src/pages/agreement/index.tsx b/src/pages/agreement/index.tsx
index f13549ef..94c30b6f 100644
--- a/src/pages/agreement/index.tsx
+++ b/src/pages/agreement/index.tsx
@@ -83,10 +83,398 @@ const Agreement: React.FC = () => {
«Сайт») и/или мобильного приложения Оператора (далее - «Приложение»). Далее по
тексту «Сайт» и «Приложение» при совместном упоминание именуются «Сервис».
-
...
-
-
-
+
+ 1.5 Настоящая Политика определяет порядок и условия осуществления обработки
+ персональных данных Субъектов, передавших свои персональные данные для
+ обработки Оператору с использованием и без использования средств
+ автоматизации, устанавливает процедуры, направленные на предотвращение
+ нарушений законодательства Российской Федерации, связанных с обработкой
+ персональных данных.
+
+
+ 1.6 Политика разработана с целью обеспечения защиты прав Субъектов при
+ обработке их персональных данных, а также продвижения товаров, работ, услуг
+ Оператора/исполнителя/доставщика путем осуществления прямых контактов с
+ потенциальным потребителем с помощью средств связи.
+
+
+ 1.7 Оператор осуществляет обработку общедоступную и иную категорию
+ персональных данных Субъекта (не относящуюся к биометрической или
+ специальной):
+
+
+ - фамилия, имя, отчество;
+
+
+ - номер телефона;
+
+
+ - адрес электронной почты;
+
+
+ - почтовый адрес;
+
+
+ - данные об оказанных и оказываемых Субъекту услугах, история заказов
+ Субъекта, обращений Субъекта к Оператору посредством использования Сервиса;
+
+
+ 1.8. При использовании Сервиса Субъектом персональных данных, Оператор
+ получает и обрабатывает данные, получаемые через программные средства Яндекс.
+ Метрика, Google tag manager, Google analytics, Google Firebase и иные
+ обезличенные данные, которые автоматически передаются в процессе использования
+ Сервиса посредством установленного на техническом устройстве Субъекта
+ программного обеспечения:
+
+
- IP-адрес;
+
+ - идентификаторы мобильных устройств;
+
+
+ - данные файлов cookie;
+
+
+ - количество просмотров;
+
+
+ - посещение страниц;
+
+
+ - поиск по сайту;
+
+
+ - поиск в приложении;
+
+
+ - точки входа (сторонние сайты, с которых пользователь по ссылке переходит на
+ сайты Компании);
+
+
+ - точки выхода (ссылки на сайтах Компании, по которым пользователь переходит
+ на сторонние сайты);
+
+
+ - страна пользователя;
+
+
+ - регион пользователя;
+
+
+ - провайдер пользователя;
+
+
+ - браузер пользователя;
+
+
+ - системные языки пользователя;
+
+
+ - ОС пользователя;
+
+
+ Мы используем такую информацию для понимания и анализа тенденций,
+ администрирования сайта, изучения поведения пользователей на сайте и сбора
+ демографической информации о нашем основном контингенте пользователей в целом.
+ Компания может использовать такую информацию в своих маркетинговых целях.
+
+
+ 1.9 Оператором на принципах:
+
+
+ - законности целей и способов обработки персональных данных, добросовестности
+ и справедливости в деятельности Оператора;
+
+
+ - ограничения обработки персональных данных достижением конкретных, заранее
+ определенных и законных целей;
+
+
+ - достоверности персональных данных, их достаточности для целей обработки,
+ недопустимости обработки персональных данных, избыточных по отношению к целям,
+ заявленным при сборе персональных данных;
+
+
+ - обработки только персональных данных, которые отвечают целям их
+ обработки.Недопустима обработка персональных данных, несовместимая с целями
+ сбора персональных данных;
+
+
+ - соответствия содержания и объема обрабатываемых персональных данных
+ заявленным целям обработки. Обрабатываемые персональные данные не должны быть
+ избыточными по отношению к заявленным целям их обработки;
+
+
+ - недопустимости объединения баз данных, содержащих персональные данные,
+ обработка которых осуществляется в целях, не совместимых между собой;
+
+
+ - обеспечения точности персональных данных, их достаточности, а в необходимых
+ случаях и актуальности по отношению к целям обработки персональных данных.
+ Оператор принимает необходимые меры либо обеспечивает их принятие по удалению
+ или уточнению неполных или неточных данных;
+
+
+ - хранения персональных данных в форме, позволяющей определить субъекта
+ персональных данных, не дольше, чем этого требуют цели обработки персональных
+ данных, если срок хранения персональных данных не установлен федеральным
+ законом, договором, стороной которого, выгодоприобретателем или поручителем по
+ которому является субъект персональных данных. Обрабатываемые персональные
+ данные подлежат уничтожению либо обезличиванию по достижении целей обработки
+ или в случае утраты необходимости в достижении этих целей, если иное не
+ предусмотрено федеральным законом.
+
+
2. Цели сбора персональных данных
+
+ 2.1. Оператор осуществляет обработку персональных данных Субъектов путем
+ ведения баз данных автоматизированным, механическим, ручным способами в целях:
+
+
+ 2.1.1. обработки заказов, запросов или других действий Субъекта, связанных с
+ регистрацией, авторизацией на Сервисе, осуществлением заказов через Сервис;
+
+
+ 2.1.2. оповещения об изменении пользовательского соглашения об условиях
+ доставки, порядка оказания услуг, меню, перечня проводимых Оператором акций,
+ скидок и иных маркетинговых мероприятий.
+
+
+ 2.1.3. в иных целях в случае, если соответствующие действия Оператора не
+ противоречат действующему законодательству, деятельности Оператора, и на
+ проведение указанной обработки получено согласие Субъекта персональных данных.
+
+
+ 2.2. Данные, указанные в п. 1.7. настоящей Политики, обрабатываются в целях
+ осуществления аналитики Сервиса, принципов использования Сервиса,
+ совершенствования функционирования Сервиса, решения технических проблем
+ Сервиса, разработки новых продуктов, расширения услуг, выявления популярности
+ мероприятий и определения эффективности рекламных кампаний; обеспечения
+ безопасности и предотвращения мошенничества, предоставления эффективной
+ клиентской поддержки.
+
+
+ 2.3. Обработка персональных данных Субъекта осуществляется Оператором с
+ момента получения согласия Субъекта персональных данных. Согласие на обработку
+ персональных данных может быть дано Субъектом персональных данных в любой
+ форме, позволяющей подтвердить факт получения согласия, если иное не
+ установлено федеральным законом: в письменной, устной или иной форме,
+ предусмотренной действующим законодательством, в том числе посредством
+ совершения Субъектом персональных данных конклюдентных действий (акцепта
+ размещенной на Сервисе оферты – пользовательского соглашения об условиях
+ доставки). В случае отсутствия согласия Субъекта персональных данных на
+ обработку его персональных данных, такая обработка не осуществляется.
+
+
+
+ 2.4. Персональные данные Субъектов получаются Оператором:
+
+
+ 2.4.1. посредством личной передачи Субъектом персональных данных при внесении
+ сведений в формы в электронном виде на Сервисе Оператора;
+
+
+ 2.4.2. посредством личной передачи Субъектом персональных данных посредством
+ сообщения в устной форме по телефону в процессе оформления заказа;
+
+
+ 2.4.3. иными способами, не противоречащими законодательству Российской
+ Федерации и требованиям международного законодательства о защите персональных
+ данных.
+
+
+ 2.5. Согласие на обработку персональных данных считается предоставленным
+ посредством совершения Субъектом персональных данных любого действия или
+ совокупности следующих действий:
+
+
+ 2.5.1. оформления заказа на Сервисе Оператора;
+
+
+ 2.5.2. проставления на Сервисе в соответствующей форме отметки о согласии на
+ обработку персональных данных;
+
+
+ 2.5.3. отправки посредством смс кода (сообщения) персональных данных при
+ авторизации/регистрации, оформления Заказа на Сервисе Оператора.
+
+
+ 3. Правовые основания обработки персональных данных
+
+
+ 3.1. Оператор обрабатывает персональные данные Субъекта на основании:
+
+
+ 3.1.1. Пользовательского соглашения об условиях доставки, размещенного на
+ Сервисе Оператора;
+
+
+ 3.1.2. Устава Оператора.
+
+
+ 3.2. Оператор обрабатывает персональные данные Субъекта только в случае их
+ отправки Субъектом через соответствующие электронные формы, расположенные на
+ Сервисе Оператора или переданные лично субъектом посредством телефонной связи
+ при оформлении Заказа. Отправляя свои персональные данные Оператору, Субъект
+ выражает свое согласие с настоящей Политикой.
+
+
+ 3.3. Оператор обрабатывает обезличенные данные о Пользователе в случае, если
+ Пользователь разрешил это в настройках браузера (включено сохранение файлов
+ «cookie» и использование технологии JavaScript) в соответствии с положениями
+ настоящей Политики.
+
+
+ 4. Порядок и условия обработки персональных данных
+
+
+ 4.1. Оператор осуществляет обработку персональных данных посредством
+ совершения любого действия (операции) или совокупности действий (операций),
+ включая следующие: сбор; запись; систематизация; накопление; хранение;
+ уточнение (обновление, изменение); извлечение; использование; передача;
+ обезличивание; блокирование; удаление; уничтожение.
+
+
+ 4.2. В соответствии с настоящей Политикой Оператор может осуществлять
+ обработку персональных данных самостоятельно, а также с привлечением третьих
+ лиц, которые привлекаются по поручению Оператора и осуществляют обработку для
+ выполнения указанных в настоящей Политики целей.
+
+
+ 4.3. В случае поручения обработки персональных данных третьему лицу, объем
+ передаваемых третьему лицу для обработки персональных данных и количество
+ используемых этим лицом способов обработки должны быть минимально необходимым
+ и для выполнения им своих обязанностей перед Оператором. В отношении обработки
+ персональных данных третьим лицом устанавливается обязанность такого лица
+ соблюдать конфиденциальность персональных данных и обеспечивать безопасность
+ персональных данных при их обработке.
+
+
+ 4.4. В процессе предоставления услуг, при осуществлении внутрихозяйственной
+ деятельности Оператор использует автоматизированную, с применением средств
+ вычислительной техники, так и неавтоматизированную, с применением бумажного
+ документооборота, обработку персональных данных.
+
+
+ 4.5. В отношении персональных данных Субъекта сохраняется конфиденциальность,
+ кроме случаев добровольного предоставления Субъектом своих персональных данных
+ неограниченному кругу лиц. В данном случае Субъект соглашается с тем, что
+ часть его персональных данных становится общедоступной.
+
+
+ 5. Сведения о реализуемых требованиях к защите персональных данных
+
+
+ 5.1. Оператор защищает персональные данные Субъекта в соответствии с
+ требованиями, предъявляемыми к защите такого рода информации, и несет
+ ответственность за использование безопасных методов защиты такой информации.
+
+
+ 5.2. Для защиты персональных данных Субъекта, обеспечения их надлежащего
+ использования и предотвращения несанкционированного и/или случайного доступа к
+ ним третьими лицами, Оператор применяет необходимые и достаточные технические
+ и административные меры. Предоставляемые Субъектом персональные данные
+ хранятся на серверах с ограниченным доступом, расположенных в охраняемых
+ помещениях.
+
+
+ 5.3. В случае подтверждения факта неточности персональных данных или
+ неправомерности их обработки, персональные данные подлежат их актуализации
+ Оператором, а обработка должна быть прекращена, соответственно.
+
+
+ 5.4. Субъект может в любой момент отозвать свое согласие на обработку
+ персональных данных при условии, что подобная процедура не нарушает требований
+ законодательства Российской Федерации.
+
+
+ 5.5. Согласие Субъекта персональных данных считается полученным в
+ установленном действующем законодательством и настоящей Политикой порядке и
+ действует до момента направления Субъектом персональных данных
+ соответствующего заявления (уведомления) о прекращении обработки персональных
+ данных по юридическому адресу Оператора:{' '}
+ юридический адрес
+
+
+ 5.5.1. В случае отзыва Субъектом персональных данных согласия на обработку его
+ персональных данных, Оператор должен прекратить их обработку или обеспечить
+ прекращение такой обработки (если обработка осуществляется другим лицом,
+ действующим по поручению Оператора) и в случае, если сохранение персональных
+ данных более не требуется для целей их обработки, уничтожить персональные
+ данные или обеспечить их уничтожение (если обработка персональных данных
+ осуществляется третьим лицом, действующим по поручению Оператора) в срок, не
+ превышающий 30 (Тридцати) дней с даты поступления указанного отзыва, если иное
+ не предусмотрено договором, стороной которого, выгодоприобретателем или
+ поручителем по которому является Субъект, иным соглашением между Оператором и
+ Субъектом персональных данных, либо если Оператор не вправе осуществлять
+ обработку персональных данных без согласия Субъекта персональных данных на
+ основаниях, предусмотренных Федеральным законом № 152-ФЗ «О персональных
+ данных» от 27.07.2006 г. или другими федеральными законами.
+
+
+ 6. Согласие на получение рекламной информации
+
+
+ 6.1. Согласие Субъекта персональных данных на получение рекламной информации,
+ подтверждается посредством:
+
+
+ 6.1.1. оформления заказа на Сервисе Оператора;
+
+
+ 6.1.2. проставления на Сервисе в соответствующей форме отметки о согласии на
+ обработку персональных данных;
+
+
+ 6.1.3. сообщения персональных данных в устной форме, при обращении по телефону
+ Оператора в процессе оформлении заказа, означает согласие Субъекта
+ персональных данных на получение от Оператора и привлеченных Оператором
+ третьих лиц, по сетям электросвязи (по предоставленным номеру мобильного
+ телефона и адресу электронной почты) информационных сообщений, а в том числе
+ информации коммерческого рекламного характера (рекламы).
+
+
+ 6.2. Давая согласие, указанное в п. 6.1. настоящей Политики, Субъект
+ персональных данных подтверждает, что действует по своей воле и в своем
+ интересе, а также то, что указанные персональные данные являются достоверными.
+
+
7. Возрастные ограничения
+
+ 7.1.В соответствии с ГК РФ несовершеннолетние в возрасте от четырнадцати до
+ восемнадцати лет вправе самостоятельно, без согласия родителей, усыновителей и
+ попечителей, распоряжаться своими заработком, стипендией и иными доходами, а
+ также совершать мелкие бытовые сделки и иные сделки, предусмотренные
+ Гражданским кодексом Российской Федерации.
+
+
+ 7.2.Только родители могут разрешать или запрещать ребенку предоставлять доступ
+ к своему аккаунту приложениям и сайтам, включив функцию родительского контроля
+ на устройстве.
+
+
+ 7.3. В случае, если мы получим достоверные сведения о возрасте Пользователя -
+ до 14 лет мы ограничим несовершеннолетнего от совершения им сделок, не
+ предусмотренных законодательством РФ, путем блокировки возможности оформления
+ заказов.
+
+
8. Заключительные положения
+
+ 8.1. Пользователь может получить любые разъяснения по интересующим вопросам,
+ касающимся обработки его персональных данных, обратившись к Оператору с
+ помощью телефонного номера{' '}
+ номер поддержки , либо на
+ почтовый адрес Оператора:{' '}
+ юридический адрес юридический
+ адрес
+
+
+ 8.2. В настоящем документе будут отражены любые изменения Политики обработки
+ персональных данных Оператором.{' '}
+
+
+ 8.3. Настоящая Политика размещена по адресу:{' '}
+
+ https://goodfood.acceleratorpracticum.ru/
+ {' '}
+
>
diff --git a/src/pages/catalog/index.tsx b/src/pages/catalog/index.tsx
index bdfcd7a0..66628c59 100644
--- a/src/pages/catalog/index.tsx
+++ b/src/pages/catalog/index.tsx
@@ -11,7 +11,7 @@ type Catalog = {
id: number;
name: string;
slug: string;
- top_three_products: Product[];
+ top_products: Product[];
};
const Catalog: React.FC = () => {
@@ -28,8 +28,6 @@ const Catalog: React.FC = () => {
.finally(() => {
setIsLoading(false);
});
-
- window.scrollTo(0, 0);
}, []);
return (
@@ -53,7 +51,7 @@ const Catalog: React.FC = () => {
title={catalog.name}
category={catalog.slug}
type="single-row"
- array={catalog.top_three_products}
+ array={catalog.top_products.slice(0, 3)}
/>
))}
diff --git a/src/pages/category/category.module.scss b/src/pages/category/category.module.scss
index 0373995c..c1ed940d 100644
--- a/src/pages/category/category.module.scss
+++ b/src/pages/category/category.module.scss
@@ -66,8 +66,10 @@
.category__sorting {
width: 328px;
margin-right: 20px;
+ padding-top: 58px;
@media screen and (width <= 768px) {
+ padding-top: 0;
margin-right: 0;
max-width: 280px;
}
diff --git a/src/pages/category/index.tsx b/src/pages/category/index.tsx
index d9bc78f2..2877acde 100644
--- a/src/pages/category/index.tsx
+++ b/src/pages/category/index.tsx
@@ -44,17 +44,13 @@ const Category: React.FC = () => {
setCategoryObj(data.results[0]?.category);
})
.catch(() => {
- navigate('/упс');
+ navigate('/404');
})
.finally(() => {
setIsLoading(false);
});
}, [category, navigate]);
- useEffect(() => {
- window.scrollTo(0, 0);
- }, []);
-
const changeCheckboxState = (e: ChangeEvent
) => {
if (e.target.name === 'vegetarian') {
setCheckboxState((prev) => ({ ...prev, vegetarian: !prev.vegetarian }));
diff --git a/src/pages/checkout/checkout-success/checkout-success.module.scss b/src/pages/checkout/checkout-success/checkout-success.module.scss
new file mode 100644
index 00000000..6e2c7444
--- /dev/null
+++ b/src/pages/checkout/checkout-success/checkout-success.module.scss
@@ -0,0 +1,107 @@
+@use '@scss/mixins' as *;
+@use '@scss/variables' as *;
+
+.checkoutSuccess {
+ display: flex;
+ flex-direction: column;
+ font-family: $ubuntu-font;
+}
+
+.checkoutSuccess__order {
+ padding: 108px 128px 100px;
+ max-width: 1024px;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ @media screen and (width <= 768px) {
+ align-items: stretch;
+ padding: 40px 20px;
+ }
+}
+
+.checkoutSuccess__title {
+ padding: 0 0 38px;
+ text-align: center;
+
+ @media screen and (width <= 768px) {
+ text-align: left;
+ padding: 0 0 20px;
+ font-size: 24px;
+ }
+}
+
+.checkoutSuccess__textContainer {
+ padding: 5px 0 74px;
+ margin: 0;
+ text-align: center;
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ padding: 0 0 32px;
+ text-align: left;
+ font-size: 14px;
+ }
+}
+
+.checkoutSuccess__text {
+ margin: 0;
+}
+
+.checkoutSuccess__advice {
+ padding: 0 0 44px;
+ margin: 0;
+ font-size: 30px;
+ font-weight: 700;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ padding: 0 0 16px;
+ font-size: 18px;
+ }
+}
+
+.checkoutSuccess__link {
+ color: var(--hint, #2180b6);
+ text-decoration: none;
+}
+
+.checkoutSuccess__ourBlock {
+ padding: 100px 128px 0;
+ max-width: 1024px;
+
+ @media screen and (width <= 768px) {
+ padding: 40px 20px 0;
+ }
+
+ @media screen and (width <= 550px) {
+ padding-right: 0;
+ }
+}
+
+.checkoutSuccess__buttonContainer {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: max-content;
+}
+
+.checkoutSuccess__error {
+ padding-top: 10px;
+ min-height: 25px;
+ text-align: center;
+ color: $error-color;
+ font-size: 13px;
+ font-weight: 300;
+ line-height: 140%;
+
+ @media screen and (width <= 768px) {
+ font-size: 12px;
+ padding-top: 3px;
+ min-height: 17px;
+ }
+}
diff --git a/src/pages/checkout/checkout-success/index.tsx b/src/pages/checkout/checkout-success/index.tsx
new file mode 100644
index 00000000..2607b63d
--- /dev/null
+++ b/src/pages/checkout/checkout-success/index.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { useLocation, useNavigate } from 'react-router';
+import { Link } from 'react-router-dom';
+
+import OurBlock from '@components/our-block';
+import OrderStatusTracker from '@components/order-status-tracker';
+import PaymentButton from '@components/payment-button';
+import { URLS } from '@data/constants';
+import { useAuth } from '@hooks/use-auth';
+
+import styles from './checkout-success.module.scss';
+
+type Order = {
+ orderNumber: string;
+ orderId: number;
+};
+
+const CheckoutSuccess: React.FC = () => {
+ const [order, setOrder] = React.useState({ orderNumber: '', orderId: 0 });
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { isLoggedIn } = useAuth();
+
+ React.useEffect(() => {
+ location.state?.orderId ? setOrder(location.state) : navigate('/', { replace: true });
+ }, [location, navigate]);
+
+ return (
+
+
+
+ Заказ №{order.orderNumber} успешно оформлен!
+
+
+
+
Мы уже приступили к его сборке.
+ {isLoggedIn ? (
+ <>
+
+ За статусом заказа можно следить в
+
+
+ личном кабинете.
+
+ >
+ ) : (
+
+ История заказов доступна для зарегистрированных пользователей.
+
+ )}
+
+
+
+
+
+ Пока вы ждёте заказ, можете ознакомиться с рецептами из нашего блога
+
+
+
+
+ );
+};
+
+export default CheckoutSuccess;
diff --git a/src/pages/checkout/checkout.module.scss b/src/pages/checkout/checkout.module.scss
index 9e58144c..e8474cee 100644
--- a/src/pages/checkout/checkout.module.scss
+++ b/src/pages/checkout/checkout.module.scss
@@ -4,7 +4,6 @@
.order {
margin: 0 auto;
width: calc(100% - 128px * 2);
- padding-top: 148px;
display: flex;
flex-direction: column;
justify-content: center;
@@ -405,6 +404,7 @@
}
/* stylelint-disable-next-line selector-attribute-quotes */
+.orderse input[type='checkbox'],
.execution__form input[type='radio'] {
display: none;
}
@@ -417,6 +417,10 @@
user-select: none;
}
+.orderse .execution__item {
+ padding-left: 30px;
+}
+
.execution__item label::after {
content: '';
display: inline-block;
@@ -435,13 +439,19 @@
}
}
+.execution__item label[for='agreement']::after {
+ bottom: 50%;
+ left: 0;
+}
+
/* stylelint-disable-next-line selector-attribute-quotes */
+.execution__item input[type='checkbox']:checked + label::after,
.execution__item input[type='radio']:checked + label::after {
background: url('@images/Radio_button.svg');
background-position: center;
background-size: 16px;
background-repeat: no-repeat;
- border: 1px solid #285718;
+ border: 1px solid $green-primary-700;
width: 16px;
height: 16px;
@@ -451,7 +461,7 @@
background-size: 10px;
/* stylelint-disable-next-line declaration-block-no-shorthand-property-overrides */
background: none;
- background-color: #285718;
+ background-color: $green-primary-700;
}
}
@@ -462,6 +472,7 @@
}
/* stylelint-disable-next-line selector-attribute-quotes */
+.execution__item input[type='checkbox']:disabled + label::after,
.execution__item input[type='radio']:disabled + label::after {
opacity: 0.5;
transition: opacity 0.5s ease-in-out;
@@ -502,7 +513,7 @@
color: #fff;
font-family: $ubuntu-font;
font-size: 14px;
- font-weight: 600;
+ font-weight: 400;
line-height: 20px;
cursor: pointer;
@@ -513,8 +524,8 @@
transition: 0.5s;
&:disabled {
- background-color: black;
- cursor: not-allowed;
+ background-color: $accent-color-lightest-green;
+ cursor: initial;
}
}
diff --git a/src/pages/checkout/index.tsx b/src/pages/checkout/index.tsx
index a88faaa4..b56ba375 100644
--- a/src/pages/checkout/index.tsx
+++ b/src/pages/checkout/index.tsx
@@ -1,63 +1,98 @@
-import React, { useEffect } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
-import styles from './checkout.module.scss';
-import api from '@services/api.ts';
+import React, { useState } from 'react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+
+import PopupCheckoutResponse from '@components/popups/popup-checkout-response';
+import Breadcrumbs from '@components/breadcrumbs';
import Input from '@ui/input';
+
+import api from '@services/api.ts';
+import { pickupPointAddresses, URLS, popupInfoText } from '@data/constants';
import { useFormAndValidation } from '@hooks/use-form-and-validation.ts';
-import { OrderPostAdd } from '@services/generated-api/data-contracts.ts';
+import { usePopup } from '@hooks/use-popup';
import { useAuth } from '@hooks/use-auth.ts';
import { useCart } from '@hooks/use-cart-context.ts';
+import type { OrderPostAdd } from '@services/generated-api/data-contracts.ts';
+import styles from './checkout.module.scss';
type Address = {
id: number;
address: string;
};
+enum deliveryTypeEnum {
+ pointOfDelivery = 'Point of delivery',
+ byCourier = 'By courier',
+}
+
+enum paymentMethodEnum {
+ pointOfDelivery = 'Payment at the point of delivery',
+ onDelivery = 'In getting by cash',
+ online = 'Online',
+}
+
const Checkout: React.FC = () => {
const { isLoggedIn, user } = useAuth();
const { loadCartData, cartData } = useCart();
+ const { handleOpenPopup, handleClosePopup } = usePopup();
const location = useLocation();
- const recievedType = location.state?.orderType;
+ const receivedType = location.state?.orderType;
+ const deliveryType = receivedType || deliveryTypeEnum.pointOfDelivery;
const { values, handleChange, errors, isValid } = useFormAndValidation();
const navigate = useNavigate();
-
- const [deliveryType, setDeliveryType] = React.useState('shipment');
const [selectedPayment, setSelectedPayment] = React.useState('');
const [selectedTime, setSelectedTime] = React.useState('');
const [selectedAddress, setSelectedAddress] = React.useState('');
- const [actualDeliveryType, setActualDeliveryType] = React.useState('');
const userAddresses = user?.addresses as unknown[] as Address[];
const [comment, setComment] = React.useState('');
- const addressesById = {
- 1: 'Ленина, 23, кв. 19',
- 2: 'Улица 2, дом 5',
- 3: 'Другой адрес и т.д.',
+ const [popupText, setPopupText] = useState('');
+ const [isAgreed, setIsAgreed] = useState(false);
+
+ const openInfoPopup = (text: string) => {
+ setPopupText(text);
+ handleOpenPopup('openPopupCheckoutResponse');
};
- const handleSubmitOrder = () => {
- if (isLoggedIn) {
- if (
- !selectedPayment ||
- !actualDeliveryType ||
- (actualDeliveryType !== 'shipment' && !selectedAddress?.toString().trim())
- ) {
- alert('Пожалуйста, заполните все обязательные поля');
- return;
- }
- } else {
- if (
- !values.order_firstName?.toString().trim() ||
- !values.order_lastName?.toString().trim() ||
- !values.order_phoneNumber?.toString().trim() ||
- !values.order_email?.toString().trim() ||
- !selectedPayment ||
- !actualDeliveryType ||
- (actualDeliveryType !== 'shipment' && !selectedAddress?.toString().trim())
- ) {
- alert('Пожалуйста, заполните все обязательные поля');
- return;
- }
+ const validateOrderData = () => {
+ switch (true) {
+ case isLoggedIn && !user.first_name:
+ return openInfoPopup(popupInfoText.fillNameAuth);
+ case isLoggedIn && !user.last_name:
+ return openInfoPopup(popupInfoText.fillSurnameAuth);
+ case isLoggedIn && !user.phone_number:
+ return openInfoPopup(popupInfoText.fillPhoneAuth);
+ case isLoggedIn &&
+ deliveryType === deliveryTypeEnum.pointOfDelivery &&
+ !selectedAddress?.toString().trim():
+ return openInfoPopup(popupInfoText.chooseAddress);
+ case isLoggedIn && !selectedAddress?.toString().trim():
+ return openInfoPopup(popupInfoText.chooseOrFillAddress);
+ case isLoggedIn && !selectedPayment:
+ return openInfoPopup(popupInfoText.choosePaymentMethod);
+ case !isLoggedIn && !values.order_firstName?.toString().trim():
+ return openInfoPopup(popupInfoText.enterName);
+ case !isLoggedIn && !values.order_lastName?.toString().trim():
+ return openInfoPopup(popupInfoText.enterSurname);
+ case !isLoggedIn && !values.order_phoneNumber?.toString().trim():
+ return openInfoPopup(popupInfoText.enterPhone);
+ case !isLoggedIn && !values.order_email?.toString().trim():
+ return openInfoPopup(popupInfoText.enterEmail);
+ case !isLoggedIn &&
+ deliveryType === deliveryTypeEnum.pointOfDelivery &&
+ !selectedAddress?.toString().trim():
+ return openInfoPopup(popupInfoText.chooseAddress);
+ case !isLoggedIn && !selectedAddress?.toString().trim():
+ return openInfoPopup(popupInfoText.enterAddress);
+ case !isLoggedIn && !selectedPayment:
+ return openInfoPopup(popupInfoText.choosePaymentMethod);
+ case !isAgreed:
+ return openInfoPopup(popupInfoText.selectAgreement);
+ default:
+ return true;
}
+ };
+
+ const handleSubmitOrder = () => {
+ if (!validateOrderData()) return;
let formData: OrderPostAdd = {
user_data: {
@@ -67,34 +102,42 @@ const Checkout: React.FC = () => {
email: values.order_email?.toString() || '',
},
payment_method: selectedPayment as
- | 'Payment at the point of delivery'
- | 'In getting by cash',
- delivery_method: actualDeliveryType as 'Point of delivery' | 'By courier',
+ | paymentMethodEnum.pointOfDelivery
+ | paymentMethodEnum.onDelivery
+ | paymentMethodEnum.online,
+ delivery_method: deliveryType as
+ | deliveryTypeEnum.pointOfDelivery
+ | deliveryTypeEnum.byCourier,
delivery_point:
- actualDeliveryType === 'shipment' ? Number(selectedAddress) || 2 : 2,
+ deliveryType === deliveryTypeEnum.pointOfDelivery
+ ? Number(selectedAddress)
+ : null,
package: 0,
comment: comment,
add_address: selectedAddress || '',
};
- if (isLoggedIn && actualDeliveryType === 'By courier') {
+ deliveryType === deliveryTypeEnum.byCourier && delete formData.delivery_point;
+ isLoggedIn && delete formData.user_data;
+
+ if (isLoggedIn && deliveryType === deliveryTypeEnum.byCourier) {
delete formData.add_address;
formData = { ...formData, address: parseInt(selectedAddress, 10) };
}
api
.usersOrderCreate(formData)
- .then(() => {
- navigate('/cart');
+ .then((res) => {
+ navigate(URLS.CART_SUCCESS, {
+ state: { orderNumber: res.order_number, orderId: res.id },
+ });
loadCartData();
- alert('заказ оформлен');
})
.catch((error) => {
- if (error.response && error.response.data && error.response.data.errors) {
- const errorMessage = error.response.data.errors;
- alert('Ошибка при создании заказа: ' + errorMessage);
+ if (error.errors[0].detail) {
+ openInfoPopup(popupInfoText.errorShort + error.errors[0].detail);
} else {
- alert('Произошла ошибка при создании заказа.');
+ openInfoPopup(popupInfoText.errorLong);
}
});
};
@@ -116,24 +159,18 @@ const Checkout: React.FC = () => {
setSelectedAddress(event.target.value);
};
- React.useState(() => {
- if (!recievedType) {
- setDeliveryType('shipment');
- } else {
- setDeliveryType(recievedType);
- }
- });
+ const handleClose = () => {
+ handleClosePopup('openPopupCheckoutResponse');
+ setPopupText('');
+ };
- useEffect(() => {
- if (deliveryType === 'shipment') {
- setActualDeliveryType('By courier');
- } else {
- setActualDeliveryType('Point of delivery');
- }
- }, [deliveryType]);
+ const handleAgreementChange = () => {
+ setIsAgreed(!isAgreed);
+ };
return (
+
Оформление заказа
@@ -220,7 +257,7 @@ const Checkout: React.FC = () => {
)}
- {deliveryType !== 'shipment' ? (
+ {deliveryType !== deliveryTypeEnum.byCourier ? (
<>
Адрес пункта самовывоза
{selectedAddress !== null ? (
@@ -232,7 +269,7 @@ const Checkout: React.FC = () => {
Выберите адрес
- {Object.entries(addressesById).map(([id, address]) => (
+ {Object.entries(pickupPointAddresses).map(([id, address]) => (
{
name="time"
value="9.00-12.00"
id="time_early-morning"
- className={styles.execution__radio}
onChange={handleTimeChange}
checked={selectedTime === '9.00-12.00'}
/>
@@ -321,7 +357,6 @@ const Checkout: React.FC = () => {
name="time"
value="12.00-15.00"
id="time_lunch"
- className={styles.execution__radio}
onChange={handleTimeChange}
checked={selectedTime === '12.00-15.00'}
/>
@@ -334,7 +369,6 @@ const Checkout: React.FC = () => {
name="time"
value="15.00-18.00"
id="time_day"
- className={styles.execution__radio}
onChange={handleTimeChange}
checked={selectedTime === '15.00-18.00'}
/>
@@ -347,7 +381,6 @@ const Checkout: React.FC = () => {
name="time"
value="18.00-21.00"
id="time_evening"
- className={styles.execution__radio}
onChange={handleTimeChange}
checked={selectedTime === '18.00-21.00'}
/>
@@ -363,25 +396,37 @@ const Checkout: React.FC = () => {
- Наличными курьеру
+ Оплата онлайн
-
-
- Оплата в пункте выдачи
-
+ {deliveryType === deliveryTypeEnum.byCourier && (
+
+
+ Оплата курьеру
+
+ )}
+ {deliveryType === deliveryTypeEnum.pointOfDelivery && (
+
+
+ Оплата в пункте выдачи
+
+ )}
@@ -411,7 +456,7 @@ const Checkout: React.FC = () => {
- {deliveryType === 'shipment' ? 'Доставка' : 'Самовывоз'}
+ {deliveryType === deliveryTypeEnum.byCourier ? 'Доставка' : 'Самовывоз'}
0 руб.
@@ -431,17 +476,33 @@ const Checkout: React.FC = () => {
!isLoggedIn && !isValid ? `${styles['orderse__buttonStyle_error']}` : ''
}`}
onClick={handleSubmitOrder}
+ disabled={cartData.products.length === 0}
>
Оформить заказ
-
- Нажимая на кнопку «Оформить заказ», вы соглашаетесь
- с условиями обработки персональных данных, а также
- с условиями продажи.
-
+
+
+
+ Я согласен с
+
+ условиями обработки персональных данных
+
+ и
+
+ условиями продажи
+
+
+
+
);
};
diff --git a/src/pages/delivery-conditions/delivery-conditions.module.scss b/src/pages/delivery-conditions/delivery-conditions.module.scss
new file mode 100644
index 00000000..f2833587
--- /dev/null
+++ b/src/pages/delivery-conditions/delivery-conditions.module.scss
@@ -0,0 +1 @@
+@use '@pages/agreement/agreement.module.scss' as *;
diff --git a/src/pages/delivery-conditions/index.tsx b/src/pages/delivery-conditions/index.tsx
new file mode 100644
index 00000000..eba27077
--- /dev/null
+++ b/src/pages/delivery-conditions/index.tsx
@@ -0,0 +1,818 @@
+import styles from './delivery-conditions.module.scss';
+
+const DeliveryConditions: React.FC = () => {
+ return (
+ <>
+
+
+
+ Пользовательское соглашение об условиях доставки
+
+
Термины и определения
+
+ В настоящем соглашении, если из контекста не следует иное, нижеприведенные
+ термины имеют следующие значения и являются её составной неотъемлемой частью:
+ «Соглашение» - настоящий документ, размещенный в сети Интернет по адресу:{' '}
+
+ https://goodfood.acceleratorpracticum.ru/
+
+ , являющееся открытым и общедоступным документом.
+
+
+ Действующая редакция Соглашения располагается в сети Интернет по адресу:{' '}
+
+ https://goodfood.acceleratorpracticum.ru/delivery-conditions
+
+
+
+ «Пользователь» - любое физическое лицо, надлежащим образом присоединившееся к
+ настоящему Соглашению для использования сайта/приложения Компании и/или
+ оформления Заказа.
+
+
+ «Компания» - Общество с ограниченной ответственностью «ГудФуд» (ОГРН номер,
+ ИНН номер), оказывающая услуги Пользователю по осуществлению Заказа Товара и
+ Доставки на условиях, предусмотренных в Пользовательском соглашении
+ посредством Сайта:{' '}
+
+ https://goodfood.acceleratorpracticum.ru/
+ {' '}
+ (далее – Сайт) и/или мобильного приложения.
+
+
+ «Исполнитель» - юридическое лицо или индивидуальный предприниматель,
+ осуществляющее(ий) приготовление/реализацию Товара для Пользователя.
+
+
+ «Доставщик» - лица, осуществляющие доставку Товара, Заказ которого оформлен
+ Пользователем (за исключением случая осуществления доставки Товара Компанией
+ Исполнителем).
+
+
+ «Товар» - пищевая продукция и напитки (за исключением алкогольных),
+ приготовление которых для Пользователей осуществляет Исполнитель в результате
+ оформления Пользователем Заказа на Сайте и/или Приложении Компании. При
+ упоминании в настоящем соглашении или иных документах Товара, имеется в виду
+ как один Товар, так и несколько Товаров, если иное не следует из соглашения
+ или соответствующего документа.
+
+
+ «Приложение» - мобильное приложение Компании, разработанное для удобства
+ Пользователя с целью выбора Товара и оформления Заказа, размещенного на Сайте.
+
+
+ «Сайт» - официальная веб-страница Компании, расположенная по адресу:{' '}
+
+ https://goodfood.acceleratorpracticum.ru/
+ {' '}
+ посредством которой Пользователь получает информацию о Товаре и оформляет
+ Заказ.
+
+
+ «Сервис» - Сайт и мобильное приложение Компании.
+
+
+ «Регистрация» - процедура внесения Пользователем своих Персональных данных в
+ определённую электронную форму на Сервисе с целью получения доступа к услугам
+ в соответствии с положениями настоящего Соглашения. Регистрация может быть
+ совершена Пользователем как отдельно, так и совместно с созданием Заказа на
+ Сервисе.
+
+
+ «Заказ» - оформленный Пользователем на Сервисе Заказ Товара и его Доставки, в
+ результате которого Пользователь заключает договор о приготовлении Товара с
+ Исполнителем, и договор доставки данного Товара на условиях, размещенных на
+ Сервисе.
+
+
+ «Договор» - договор о приготовлении/ реализации Товара и/или его доставки,
+ заключаемый между Пользователем и Исполнителем в результате оформления Заказа.
+
+
+ «Доставка» - услуга доставки Товара до адреса, указанного Пользователем, Заказ
+ которого Пользователь оформил на Сайте или в Приложении,. Заключение с
+ Пользователем договора о доставке осуществляет Доставщик, либо,
+ непосредственно Исполнитель посредством оформления Пользователем Заказа на
+ сервисе. Исполнитель или Доставщик имеют право привлекать третьих лиц для
+ осуществления доставки Товара до Пользователя.
+
+
+ «Промокод» - определенная последовательность символов, при условии активации
+ которой и соблюдении иных условий использования Промокода Пользователю
+ предоставляется скидка на стоимость Товара и/или Доставки.
+
+
+ «Персональные данные» – личная информация (включая фамилию, имя, отчество,
+ дату рождения, почтовый адрес, контактный телефон и адрес электронной почты, а
+ также другую информацию, которую Пользователь сообщает, используя Сервис),
+ данную информацию Пользователь предоставляет осознанно и добровольно в момент
+ Регистрации на Сервисе и/или оформления Заказа на Сервисе в соответствии с
+ положениями настоящего Соглашения и Политики хранения и обработки персональных
+ данных.
+
+
+ «Пользовательское соглашение» — настоящий документ, размещенный в сети
+ Интернет по адресу:{' '}
+
+ ссылка на страницу с данным соглашением
+
+
+
+ «Политика обработки и хранения персональных данных» - документ, определяющий
+ порядок и условия осуществления обработки персональных данных Пользователей,
+ размещенный в сети Интернет по адресу:{' '}
+
+ https://goodfood.acceleratorpracticum.ru/
+
+
+
+ 1. Общие положения. Предмет соглашения
+
+
+ 1.1. Перед тем как начать использовать Сервис Пользователь обязан ознакомиться
+ с настоящим Соглашением и присоединиться к нему. Пользователь подтверждает,
+ что прочитал, понял и полностью согласен соблюдать настоящее Соглашение.
+
+
+ Обращаем Ваше внимание, что родители несут полную ответственность за жизнь и
+ здоровье своих детей (согласно ст.63, 65 Семейного кодекса РФ, ст.5.35. КоАП
+ РФ.). Употребление содержащихся в составе блюд морепродуктов, морской капусты,
+ а также имбиря и вассаби должно соответствовать определенному этапу
+ формирования детского организма, с учетом возможных аллергических реакций.
+ Рекомендуем до 7-8 летнего возраста приобретать для детей блюда из раздела
+ "Детское меню".
+
+
+ 1.2. Сервис предлагает Пользователю доступ к поиску и Заказу готовой еды на
+ условиях, предусмотренных настоящим Соглашением. В соответствии с настоящим
+ соглашением Пользователь при оформлении Заказа заключает Договор с Компанией,
+ в связи с чем возникают прямые договорные отношения с Исполнителем в части
+ приготовления и/или реализации Товара и осуществления Доставки.
+
+
+ 1.3. В соответствии со статьей 437 Гражданского Кодекса Российской Федерации
+ (далее по тексту соглашения - ГК РФ) настоящее соглашение является публичной
+ офертой, адресованной физическим лицам, и в случае принятия изложенных условий
+ в настоящем соглашении, физическое лицо обязуется произвести оплату Товара на
+ условиях, изложенных в настоящем соглашении.
+
+
+ В соответствии с пунктом 3 статьи 438 ГК РФ, момент окончательного
+ подтверждения Заказа Пользователем является акцептом оферты, что является
+ равносильным заключению Договора розничной купли-продажи Товара на условиях,
+ установленных в настоящем соглашении и на Сервисе.
+
+
+ 1.3.1. Компания или Исполнитель вправе отказаться от заключения публичного
+ договора при отсутствии технической (поломки оборудования, в том числе при
+ принятии/подтверждении заказа), физической (удаленность Потребителя от
+ территории оказания услуг) и иной возможности предоставить потребителю товары,
+ услуги надлежащего качества.
+
+
+ 1.4. Принимая условия настоящего Соглашения, Пользователь подтверждает свое
+ информированное и добровольное согласие на обработку его персональных данных,
+ предоставленных при регистрации, в том числе, но не ограничиваясь: для ответа
+ на обращения Пользователя в службу технической поддержки Сервиса, для
+ разрешения возможных претензий, для участия в стимулирующих, рекламных,
+ маркетинговых и иных мероприятиях, направленных на продвижение услуг Компании,
+ партнеров Исполнителя и иных третьих лиц. Компания вправе направлять
+ Пользователю информацию о функционировании Сервиса на адрес электронной почты,
+ на номер телефона, указанные Пользователем, а также направлять собственные или
+ принадлежащие Компании информационные, рекламные или иные сообщения, или
+ размещать соответствующую информацию в самом сервисе. Также Пользователь
+ подтверждает свое согласие на передачу указанных выше персональных данных
+ Компании и их обработку Компанией в целях исполнения настоящего Соглашения и
+ реализации функционирования Сервиса, а также разрешения претензий, связанных с
+ исполнением настоящего Соглашения.
+
+
+ Принимая условия настоящего Соглашения, Пользователь в том числе принимает
+ условия Политики хранения и обработки персональных данных.
+
+
+ 1.5. Настоящее соглашение может быть изменено Компанией в одностороннем
+ порядке. Уведомление пользователей, при внесении изменений в Соглашение,
+ происходит путем размещения новой редакции Соглашения по постоянному адресу{' '}
+
+ https://goodfood.acceleratorpracticum.ru/delivery-conditions
+ {' '}
+ не позднее, чем за 10 дней до вступления в силу соответствующих изменений.
+ Предыдущие редакции Соглашения хранятся в архиве документации Компании
+ Заказчика. При этом продолжение использования сервиса после внесения изменений
+ и/или дополнений в настоящее Соглашение, означает согласие Пользователя с
+ такими изменениями и/или дополнениями, в связи с чем Пользователь обязуется
+ регулярно отслеживать изменения в соответствующем разделе и в Соглашении,
+ размещенном на сайте{' '}
+
+ https://goodfood.acceleratorpracticum.ru/delivery-conditions
+ {' '}
+ Пользователь вправе отказаться от принятия изменений Соглашения, что означает
+ отказ Пользователя от использования сервиса.
+
+
+ 2. Права и обязанности Пользователя
+
+
+ 2.1. Пользователь обязуется надлежащим образом соблюдать условия настоящего
+ Соглашения.
+
+
+ 2.2. При заказе посредством Сервиса Пользователь обязуется сообщать
+ достоверную информацию о себе для надлежащего исполнения обязательств,
+ предусмотренных настоящим соглашением.
+
+
+ 2.3. Пользователь обязуется принимать надлежащие меры для обеспечения
+ сохранности его мобильного устройства и несет личную ответственность за
+ сохранность личных данных, указанных на сервисе, за безопасность своего логина
+ и пароля.
+
+
+ 2.4. Пользователь обязуется не использовать сервис для любых иных целей, кроме
+ как для целей, связанных с личным некоммерческим использованием.
+
+
+ 2.5. Пользователь обязуется, пользуясь сервисом, не вводить в заблуждение
+ других Пользователей и третьих лиц.
+
+
+ 2.6. Пользователь обязуется не использовать стороннее программное обеспечение
+ и другие технические средства, влияющие на работу сервиса и связанную с ней
+ систему.
+
+
3. Права и обязанности Компании
+
+ 3.1. Компания вправе заблокировать доступ Пользователя к Сервису в случае
+ обнаружения нарушений Пользователем обязанностей, указанных в разделе 2
+ настоящего Соглашения.
+
+
+ 3.2. Компания оставляет за собой право в любой момент расторгнуть настоящее
+ Соглашение по организационным или техническим причинам в одностороннем
+ порядке, заблокировав возможность его использования.
+
+
+ 3.3. Компания осуществляет обработку персональных данных пользователя в целях
+ исполнения и на условиях настоящего Соглашения.
+
+
4. Оплата Товара и/или Доставки
+
+ 4.1. Оплата Товара и/или Доставки в рамках оформленного Пользователем Заказа,
+ может быть произведена Пользователем:
+
+
+ 4.1.1. Непосредственно Исполнителю наличными денежными средствами или
+ посредством банковской карты через терминал оплаты в момент доставки Товара.
+ Указанный вид оплаты осуществляется без участия Компании и не регулируется
+ положениям настоящего Соглашения.
+
+
+ 4.1.2. Пользователю может быть доступна функция оплаты Доставщику (либо
+ третьему лицу, привлекаемому Доставщиком, в случае, если Доставщик привлекает
+ такое третье лицо для осуществления Доставки Товара до Пользователя) наличными
+ денежными средствами в случае осуществления Доставки Товара Доставщиком. В
+ этом случае Доставщик (либо привлекаемое им третье лицо) в части приема
+ денежных средств за Товар действует по поручению Исполнителя.
+
+
+ 4.1.3. Пользователю может быть доступна функция безналичной оплаты в
+ интерфейсе Сервиса с Привязанной банковской карты (п. 4.3. настоящего
+ соглашения). В этом случае оплата происходит через авторизационный сервер
+ Процессингового центра Банка с использованием Банковских кредитных карт
+ следующих платежных систем: МИР VISA InternationalMasterCard World Wide К
+ оплате принимаются все виды платежных карточек VISA, за исключением Visa
+ Electron. В большинстве случаев карта Visa Electron не применима для оплаты
+ через интернет, за исключением карт, выпущенных отдельными банками. О
+ возможность оплаты картой Visa Electron вам нужно выяснять у банка-эмитента
+ вашей карты.
+
+
+ Доставщика с привлечением уполномоченного оператора по приему платежей или
+ оператора электронных денежных средств и является получателем платежа в
+ качестве агента Исполнителя и/или Доставщика (далее – «безналичная оплата»).
+ Компания не гарантирует отсутствие ошибок и сбоев в работе сервиса в отношении
+ предоставления возможности безналичной оплаты. Компания вправе
+
+
+ 4.1.3.2. Для оплаты Заказа Покупатель перенаправляется на платежный шлюз Банка
+ для ввода реквизитов карты. Соединение с платежным шлюзом и передача
+ информации осуществляется в защищенном режиме с использованием протокола
+ шифрования SSL.
+
+
+ В случае если банк Покупателя поддерживает технологию безопасного проведения
+ интернет-платежей Verified By Visa или MasterCard Secure Code для проведения
+ платежа также может потребоваться ввод специального пароля. Проведение
+ платежей по банковским картам осуществляется в строгом соответствии с
+ требованиями платежных систем Visa Int. и MasterCard Europe Sprl. Введенная
+ информация не будет предоставлена третьим лицам за исключением случаев,
+ предусмотренных законодательством РФ.
+
+
+ Способы и возможность получения паролей для совершения интернет-платежей
+ Пользователь может уточнить в банке, выпустившем карту.
+
+
+ 4.1.4. Компания не гарантирует отсутствие ошибок и сбоев в работе Сервиса в
+ отношении предоставления возможности оплаты наличными или безналичными
+ денежными средствами. Выбор соответствующей формы оплаты производится
+ Пользователем в интерфейсе Сервиса. При этом, доступный Пользователю в
+ конкретный момент времени способ оплаты Товара и/или Доставки определяется с
+ учетом технических, временных, материальных и/или иных факторов.
+
+
+ 4.2. Прием денежных средств Компанией в случае, предусмотренном п. 4.1.3.
+ настоящего соглашения, осуществляется исключительно в связи с тем, что
+ Компания посредством предоставления возможности оформления Заказа на сервисе
+ участвует в реализации Исполнителем Пользователю Товаров, в оплату которых
+ принимаются денежные средства. При этом прием денежных средств, в случае,
+ предусмотренном п. 4.1.2. настоящего соглашения, осуществляется Доставщиком,
+ привлекаемым Исполнителем (либо третьим лицом, привлекаемым Доставщиком), в
+ связи с доставкой Товара Пользователю.Компания и Доставщик не являются
+ платежными агентами при проведении расчетов в соответствии с настоящим
+ соглашением согласно пп. 1, 4 ч. 2 ст. 1 Федерального закона от 03.06.2009 №
+ 103-ФЗ «О деятельности по приему платежей физических лиц, осуществляемой
+ платежными агентами».
+
+
+ 4.3. Привязанная банковская карта может указываться Пользователем в интерфейсе
+ сервиса, при этом Пользователь указывает следующие данные:
+
+
+ - Hаименование владельца банковской карты;
+
+
+ - номер банковской карты;
+
+
+ - срок окончания действия банковской карты, месяц/год;
+
+
+ - CVV код для карт Visa / CVC код для Master Card.
+
+
+ Если на банковской карте код CVC / CVV отсутствует, то, возможно, карта не
+ пригодна для CNP транзакций (т.е. таких транзакций, при которых сама карта не
+ присутствует, а используются её реквизиты), и в данном случае следует
+ обратиться в банк для получения подробной информации.
+
+
+ Если данные банковской карты верны, действительны и использование данной карты
+ в рамках сервиса технически возможно, указанная банковская карта приобретает
+ статус Привязанной и может быть использована для безналичной оплаты. Все
+ Привязанные карты отображаются в интерфейсе сервиса.
+
+
+ 4.4. Безналичная оплата осуществляется Пользователем с участием
+ уполномоченного оператора по приему платежей или оператора электронных
+ денежных средств и регулируется правилами международных платежных систем,
+ банков (в том числе банка-эмитента Привязанной карты) и других участников
+ расчетов.
+
+
+ 4.5. При указании своих данных согласно п. 4.3. настоящего соглашения и
+ дальнейшем использовании Привязанной карты Пользователь подтверждает и
+ гарантирует указание им достоверной и полной информации о действительной
+ банковской карте, выданной на его имя, соблюдение им правил международных
+ платежных систем и требований банка-эмитента, выпустившего Привязанную карту,
+ в том числе в отношении порядка проведения безналичных расчетов.
+
+
+ 4.6. Пользователь понимает и соглашается, что все действия, совершенные в
+ рамках сервиса после авторизации с помощью логина и пароля, присвоенных ему
+ при регистрации на Сервисе, в том числе по безналичной оплате с использованием
+ Привязанной банковской карты, считаются совершенными Пользователем.
+
+
+ 4.7. В случае несогласия Пользователя с фактом и/или суммой безналичной
+ оплаты, а также получения некачественного, некомплектного Товара, либо
+ несоответствия полученного Товара заказанному, Пользователь вправе обратиться
+ к Компании по реквизитам, указанным в настоящем соглашении, в течение 2
+ календарных дней с указанием событий, послуживших причиной обращения.
+
+
+ При получении Товара Пользователь проверяет его соответствие Заказу,
+ комплектность и отсутствие претензий к внешнему виду доставленного Товара. В
+ случае выявления несоответствий Пользователь вправе потребовать замены на
+ Товары надлежащего качества сразу в момент получения, уведомив об этом
+ Доставщика, либо в течение 5 минут после получения Товара, уведомив об этом
+ Компанию; либо потребовать произвести возврат Пользователю ранее оплаченных
+ денежных средств за данный Товар из Заказа.
+
+
+ Возврат денежных средств Пользователю производится на банковскую карту
+ Пользователя в течение 5 (Пяти) банковских дней, начиная со следующего
+ банковского дня с момента выставления законного и обоснованного требования о
+ возврате денежных средств.
+
+
+ Компания/Исполнитель вправе отказать Пользователю в обмене Товара или возврате
+ денежных средств по своему усмотрению, если будет иметь доказательства
+ неправомерных действий со стороны Пользователя. Также Компания/Исполнитль
+ вправе привлечь Пользователя к ответственности в судебном порядке.
+
+
+ 4.8. По вопросам, связанным с оплатой Товара и/или Доставки способами,
+ предусмотренными пп. 4.1.1., 4.1.2. настоящего соглашения, Пользователь вправе
+ обратиться к Исполнителю и/или к Доставщику.
+
+
+ 4.9. Компания оставляет за собой право в любой момент потребовать от
+ Пользователя подтверждения данных, указанных им в рамках Сервиса, в том числе
+ данных Привязанной карты, и запросить в связи с этим подтверждающие документы
+ (в частности, документы, удостоверяющие личность), непредставление которых, по
+ усмотрению Компании, может быть приравнено к предоставлению недостоверной
+ информации и повлечь последствия, предусмотренные настоящим соглашением.
+
+
+ 5. Гарантии и ответственность сторон
+
+
+ 5.1. Пользователь гарантирует, что не будет предпринимать каких-либо действий,
+ направленных на причинение ущерба Исполнителю, Компании, операторам сотовой
+ мобильной связи, правообладателям или иным лицам.
+
+
+ 5.2. В случае нарушения правил использования сервиса, указанных в разделе 2
+ настоящего Соглашения, Пользователь обязуется возместить вред, причиненный
+ такими действиями.
+
+
+ 5.3. Если Пользователем не доказано обратное, любые действия, совершенные с
+ использованием его мобильного устройства, считаются совершенными
+ соответствующим Пользователем.
+
+
+ 5.4. Компания не является уполномоченной организацией по смыслу Закона РФ от
+ 07.02.1992 г. № 2300-1 «О защите прав потребителей», и не осуществляет
+ рассмотрение и удовлетворение претензий Пользователей в отношении Товара и/или
+ Доставки ненадлежащего качества, Заказ которого (которых) оформлен
+ Пользователем на Сервисе.При обращении Пользователя к Компании по вопросам,
+ касающимся Договора, заключаемого в результате оформления Заказа, в том числе
+ с претензиями относительно исполнения данного Договора, Компания вправе
+ передать соответствующую информацию Исполнителю и/или Доставщику, а также
+ передать Пользователю информацию, полученную от Исполнителя и/или Доставщика
+ по данным вопросам.
+
+
+ 5.5. Пользователь при оформлении Заказа, в соответствии с условиями настоящего
+ Соглашения, подтверждает личное ознакомление с ингредиентами, входящими в
+ состав Заказа, которые могут быть несовместимыми с организмом Пользователя в
+ силу индивидуальных особенностей, в частности, могут вызвать отторжение или
+ аллергическую реакцию.
+
+
+ Пользователь при наступлении указанного в настоящем пункте случая -
+ индивидуальной несовместимости Продукции с организмом Пользователя,
+ подтверждает, что такой случай индивидуальной несовместимости не связан с
+ качеством продукции и соблюдения всех норм, хранения, транспортировки и
+ приготовления Заказа, а является индивидуальной особенностью организма
+ Пользователя о которой Исполнителю не могло быть известно, в связи с чем,
+ Исполнитель не несет ответственности при наступлении такого случая.
+
+
6. Сайты третьих лиц
+
+ 6.1. Сервис может содержать ссылки или представлять доступ на другие сайты в
+ сети Интернет (сайты третьих лиц) и размещенный на данных сайтах контент,
+ являющийся результатом интеллектуальной деятельности третьих лиц и охраняемый
+ в соответствии с законодательством Российской Федерации. Указанные сайты и
+ размещенный на них контент не проверяются Компанией на соответствие
+ требованиям законодательства Российской Федерации.
+
+
+ 6.2. Компания не несет ответственность за любую информацию или контент,
+ размещенные на сайтах третьих лиц, к которым Пользователь получает доступ
+ посредством сервиса, включая, в том числе, любые мнения или утверждения,
+ выраженные на сайтах третьих лиц.
+
+
+ 6.3. Пользователь подтверждает, что с момента перехода Пользователя по ссылке,
+ содержащейся на сервисе на сайт третьего лица, взаимоотношения Компании и
+ Пользователя прекращаются, настоящее Соглашение в дальнейшем не
+ распространяется на Пользователя, и Компания не несет ответственность за
+ использование Пользователем контента, правомерность такого использования и
+ качество контента, размещенного на сайтах третьих лиц.
+
+
+ 7. Конфиденциальность и обработка персональных данных Пользователя.
+
+
+ 7.1. Персональные данные Пользователя обрабатываются в соответствии
+ Федеральным законом от 27.07.2006 г. № 152-ФЗ «О персональных данных» и
+ Политикой хранения и обработки персональных данных.
+
+
+ 7.2. При регистрации/оформлении заказа Пользователь предоставляет следующие
+ данные: имя, адрес электронной почты, номер контактного телефона, адрес
+ доставки заказа.
+
+
+ 7.3. В целях исполнения настоящего соглашения Компания развивает, оптимизирует
+ и внедряет новый функционал сервиса (включая продукты информационного,
+ рекламного, развлекательного и иного характера), в т.ч. с участием
+ аффилированных лиц и/или партнеров.
+
+
+ Для обеспечения реализации указанных целей, а также в целях информирования
+ Пользователей о своих услугах, продвижения товаров и услуг, проведения
+ электронных и sms опросов, Получения Пользователем персонализированной
+ (таргетированной) рекламы, контроля маркетинговых акций, клиентской поддержки,
+ организации доставки товара Пользователям, проведения розыгрышей призов среди
+ Пользователей, контроля удовлетворенности Пользователя и качества услуг,
+ проверки, исследования и анализа таких данных Пользователь при
+ регистрации/оформлении заказа соглашается и поручает Компании осуществлять с
+ соблюдением применимого законодательства обработку данных, в т.ч. результатов
+ автоматизированной обработки таких данных в виде целочисленных и/или текстовых
+ значений и идентификаторов, их передачу аффилированным лицам и/или
+ Исполнителям/Доставщикам во исполнение такого поручения на обработку, а также
+ осуществлять сбор (получение) данных Пользователя и иных связанных с
+ Пользователем данных от аффилированных лиц и/или Исполнителей.
+
+
+ 7.4. Под данными, связанными с Пользователем, понимается информация о
+ технических средствах (устройствах) и способах технологического взаимодействия
+ с сервисом и/или сервисами аффилированных лиц и/или партнеров (в т. ч.
+ IP-адрес хоста, вид операционной системы, тип браузера, географическое
+ положение, данные о провайдере и иное), об активности Пользователя, а также
+ иные данные, получаемые указанными способами.
+
+
+ 7.5. Под обработкой данных понимается любое действие (операция) или
+ совокупность действий (операций), совершаемых с использованием средств
+ автоматизации или без использования таких средств с персональными данными
+ Пользователя, включая сбор, запись, систематизацию, накопление, хранение,
+ уточнение (обновление, изменение), сопоставление, извлечение, использование,
+ передача аффилированным лицам Компании и/или Исполнителя в целях и на условиях
+ настоящего Соглашения, обезличивание, блокирование, удаление, уничтожение.
+
+
+ 7.6. Компания имеет право отправлять Пользователю от своего имени
+ самостоятельно или с привлечением технических партнеров информационные, в том
+ числе сервисные и рекламные сообщения, на электронную почту Пользователя,
+ мобильный телефон (смс, телефонные звонки) или через используемые им сервисы
+ Компании (социальные сети, мессенджеры и иные). Пользователь вправе отказаться
+ от получения рекламной и другой информации без объяснения причин отказа.
+ Сервисные сообщения, информирующие Пользователя о заказе и этапах его
+ обработки, отправляются автоматически и не могут быть отклонены Пользователем.
+
+
+ 7.7. Компания вправе использовать технологию «cookies». «Cookies» не содержат
+ конфиденциальную информацию, и Компания вправе передавать информацию о
+ «cookies» Исполнителям, агентам и третьим лицам, имеющим заключенные с
+ Компанией договоры, для исполнения обязательств перед Пользователем и для
+ целей статистики и оптимизации рекламных сообщений.
+
+
+ 7.8. Компания получает информацию об ip-адресе посетителя Сайта{' '}
+
+ https://goodfood.acceleratorpracticum.ru/
+
+ . Данная информация не используется для установления личности посетителя.
+
+
+ 7.9. Компания не несет ответственности за сведения, предоставленные
+ Пользователем на Сайте в общедоступной форме.
+
+
+ 7.10. Компания вправе осуществлять записи телефонных разговоров с
+ Пользователем. При этом Компания обязуется предотвращать попытки
+ несанкционированного доступа к информации, полученной в ходе телефонных
+ переговоров, и/или передачу ее третьим лицам, не имеющим непосредственного
+ отношения к исполнению заказов в соответствие с п. 4 ст. 16 Федерального
+ закона «Об информации, информационных технологиях и защите информации».
+
+
+ 8. Регистрация на Сервисе, пароль и безопасность
+
+
+ 8.1. Для получения права использования Пользователем сервиса Пользователю
+ рекомендуется осуществить регистрацию учетной записи Пользователя на сервисе.
+
+
+ 8.2. Регистрация Пользователя осуществляется следующим образом:
+
+
+ а) ввести в форму сервиса абонентский номер телефона в федеральном формате
+ (89ХХХХХХХХХ), указанный Пользователем при регистрации абонентский номер
+ телефона будет использоваться в качестве имени Пользователя (логин) при
+ использовании сервиса;
+
+
+ б) ввести пароль, который придет на указанный номер мобильного телефона в виде
+ sms-сообщения. В последующем пароль может быть изменен Пользователем в личном
+ кабинете своего профиля;
+
+
+ в) принять лицензионное соглашение – в случае работы с Приложением;
+
+
+ г) принять настоящее соглашение-оферту, согласившись с её условиями;
+
+
+ д) при желании дать свое согласие на получение информации (рекламы) о
+ проводимых акциях Компании в соответствии с условиями настоящего соглашения.
+
+
+ 8.3. Совершая действия по регистрации учетной записи Пользователя в Сервисе,
+ Пользователь принимает условия настоящего Соглашения, в полном объеме и без
+ каких-либо изъятий.
+
+
+ 8.4. Регистрация Пользователя позволяет избежать несанкционированных действий
+ третьих лиц от имени Пользователя и открывает последнему доступ к
+ дополнительным сервисам. Передача Пользователем логина и пароля третьим лицам
+ не допускается.
+
+
+ 8.5. Заказ Товара осуществляется Пользователем как через сервис, так и по
+ телефону.
+
+
+ 9. Оформление и сроки выполнения заказа
+
+
+ 9.1. Заказ Пользователя может быть оформлен по телефону и/или посредством
+ заполнения электронной формы Заказа на Сервисе.
+
+
+ 9.1.1. При оформлении Заказа Пользователь сам подтверждает, что ознакомлен с
+ условиями настоящего соглашения и обязуется предоставить всю информацию,
+ необходимую для надлежащего оформления и исполнения Заказа.
+
+
+ 9.1.2. При оформлении Заказа через Сервис Пользователь заполняет электронную
+ форму Заказа и отправляет сформированный Заказ путем подтверждения Заказа в
+ электронной форме.
+
+
+ 9.1.3. Для приема в обработку Заказа, который был оформлен Пользователем через
+ Сервис Компании необходимо подтверждение Компании посредством телефонного
+ звонка на контактный номер Пользователя в том, что данный Заказ получен,
+ принят и передан в обработку Исполнителю. Заказ считается принятым в
+ обработку, начиная с момента его подтверждения.
+
+
+ 9.1.4. Если Заказ, который был оформлен Пользователем через сервис Компании,
+ не был подтвержден со стороны Компании Пользователю, то Пользователь должен
+ самостоятельно убедиться по телефону 8(812)383-0-383 в том, что его Заказ был
+ получен, принят и передан в обработку Исполнителю.
+
+
+ 9.2. Пользователь может заказать только те Товары, которые есть в наличии у
+ Исполнителя в момент оформления Заказа.
+
+
+ 9.2.1. Если у Исполнителя отсутствует необходимое количество или ассортимент
+ заказанного Пользователем Товара, Компания информирует об этом Пользователя по
+ телефону в течение 30 минут после получения Заказа от Пользователя.
+ Пользователь вправе согласиться принять Товар в ином количестве или
+ ассортименте, либо аннулировать свой Заказ. В случае неполучения ответа
+ Пользователя Заказ Пользователя аннулируется в полном объеме.
+
+
+ 9.3. Пользователь не имеет право изменить состав Заказа.
+
+
+ 9.4. В случае возникновения у Пользователя дополнительных вопросов, касающихся
+ характеристик Товара, перед оформлением Заказа, Пользователь должен обратиться
+ к Компании по телефону 8(812)383-0-383 для получения необходимой информации.
+
+
+ 9.5. Исполнитель получает информацию о Заказе Пользователя в течение 5 минут с
+ момента приема Заказа Компанией. Исполнитель приступает к выполнению Заказа в
+ порядке очередности всех Заказов, находящихся у него на исполнении.
+
+
+ 9.6. Компания при оформлении Заказа от Пользователя информирует последнего о
+ планируемом времени доставки Заказа по адресу Пользователя. Если при
+ оформлении Заказа Пользователь не аннулировал Заказ и данный Заказ был
+ оформлен Компанией, соответственно Пользователь согласен с обозначенным ему
+ временем доставки Заказа и Заказ будет передан Исполнителю для выполнения.
+
+
10. Доставка Товара
+
+ 10.1. В случае если Товар не был передан Пользователю по вине последнего,
+ отказа Пользователя от приемки и/или оплаты им Товара, ложного вызова, логин
+ Пользователя (абонентский номер телефона) подлежит блокированию и в дальнейшем
+ Заказы от данного Пользователя по телефону и/или через сервис не принимаются.
+
+
+ 10.2. Доставка Товара осуществляется по фактическому адресу, указанному
+ Пользователем при оформлении Заказа через сервис и/или по телефону Компании.
+
+
+ 10.2.1. Средний срок доставки оформленного Заказа составляет 45-60 минут.
+ Данное время может быть увеличено в виду погодных условий, ситуации на дороге,
+ загруженностью на кухне Исполнителя.
+
+
+ 10.2.2. Возможность доставки Товара за пределы зоны доставки Пользователь
+ обязан предварительно согласовать с Исполнителем. Исполнитель вправе отказать
+ в доставке Заказа, если он не входит в пределы зоны доставки.
+
+
+ 10.2.3. Компания не несет ответственности за соблюдение/несоблюдение
+ Исполнителем и/или Доставщиком своих обязательств перед Пользователями, а
+ также за достоверность информации, предоставленной такими службами. Компания
+ со своей стороны способствует урегулированию различных ситуаций, возникающих
+ между Пользователем и Исполнителем и/или Доставкой, но не гарантирует
+ положительное и окончательное их решение для той, или иной Стороны.
+
+
+ 10.3. Доставка осуществляется при условии, что Пользователь сделает Заказ на
+ сумму минимального заказа. Сумма минимального заказа определяется Исполнителем
+ и в одностороннем порядке и указывается на Сервисе Компанией.
+
+
11. Форс-мажор
+
+ 11. Любая из Сторон в соответствии с настоящим соглашением, освобождается от
+ ответственности за полное или частичное неисполнение своих обязательств по
+ настоящему соглашению, если это неисполнение было вызвано обстоятельствами
+ непреодолимой силы. Обстоятельства непреодолимой силы означают чрезвычайные
+ события и обстоятельства, которые Стороны не могли ни предвидеть, ни
+ предотвратить разумными средствами. Такие чрезвычайные события или
+ обстоятельства включают в себя, в частности: забастовки, наводнения, пожары,
+ землетрясения и иные стихийные бедствия, войны, военные действия и т.д.
+
+
+ 12. Допущения при производстве продукции, согласно технологическим картам.
+
+
+ 12.1. Запеченные роллы: запекание – это процесс приготовления роллов и суши, и
+ не является его температурной характеристикой;
+
+
+ 12.2. Роллы с крабовым мясом: допускается попадание кусочков хитина/осколков
+ панциря не более 5 мм;
+
+
+ 12.3. Лосось: допускаются разные оттенки лосося с широкими прожилками при
+ смене поставщиков на производстве;
+
+
+ 12.4. Темный цвет у авокадо: цвет у авокадо зависит от окисляемости продукта,
+ по стандартам изготовления цвет не влияет на вкусовые ощущения;
+
+
+ 12.5. Мидии: допускается попадание мяса краба в блюда с мидиями и кусочков
+ жемчуга;
+
+
+ 12.6. Неочищенный картофель: при приготовлении некоторых блюд, в том числе
+ супов, используется молодой картофель в мундире;
+
+
+ 12.7. Мидии в сливочном соусе: допускается попадание закрытых мидий не более
+ 10% от общего количества;
+
+
+ 12.8. Французский томатный суп с морепродуктами: допускается попадание
+ закрытых мидий;
+
+
+ 12.9. Допускается попадание кожуры от семечек в блюдах с ростками подсолнуха;
+
+
13. Заключительные положения
+
+ 13. Настоящее Соглашение вступает в силу для Пользователя с момента
+ регистрации на Сервисе и/или оформления Заказа и действует до тех пор, пока не
+ будет изменено или расторгнуто по инициативе Пользователя или Компании.
+
+
+ 13.1. Настоящее Соглашение составлено на русском языке.
+
+
+ 13.2. Если какое-либо из положений настоящего Соглашения будет признано
+ недействительным, это не оказывает влияния на действительность или
+ применимость остальных положений настоящего Соглашения.
+
+
+ 13.3. Дальнейшее использование сервиса означает, что Пользователь принял на
+ себя ответственность за безусловное соблюдение настоящего Соглашения.
+
+
14. Информация о Компании
+
ООО «ГудФуд»
+
ИНН/КПП:
+
ОГРН:
+
+ Телефон: 8-800-000-444-333
+
+
+ Адрес для переписки:
+
+
+ Дата публикации последней редакции Соглашения об условиях доставки 20.11.2023
+ г.
+
+
+ Дата вступления в силу последней редакции Соглашения об условиях доставки
+ 20.11.2023 г.
+
+
+
+ >
+ );
+};
+
+export default DeliveryConditions;
diff --git a/src/pages/home/home.module.scss b/src/pages/home/home.module.scss
index 83406b75..6d356395 100644
--- a/src/pages/home/home.module.scss
+++ b/src/pages/home/home.module.scss
@@ -15,15 +15,16 @@
}
.home__catalogSection {
- margin-top: 120px;
+ max-width: 80%;
+ margin: 120px auto 0;
@media screen and (width <= 768px) {
margin: 50px auto 0;
- max-width: 550px;
+ max-width: calc(100% - 2 * 20px);
}
@media screen and (width <= 400px) {
- margin-top: 0;
+ margin: 0 auto;
}
}
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
index 31d6b21f..76df54e8 100644
--- a/src/pages/home/index.tsx
+++ b/src/pages/home/index.tsx
@@ -4,15 +4,16 @@ import styles from './home.module.scss';
import InfoCard from '@components/info-card';
import usefulProductsIcon from '@images/useful-products-icon.svg';
import roundTheClockDelivery from '@images/round-the-clock-delivery.svg';
-import CardCatalogLink from '@components/card-catalog-link';
-import { mainPageBlockLinks } from '@data/dataExamples.ts';
import TopSellingThisWeek from '@components/top-selling-this-week';
import AboutCompany from '@components/about-company/index.tsx';
import OurBlock from '@components/our-block';
+import CatalogPromo from '@components/catalog-promo';
+import ScrollToAnchorHash from '@components/scroll-to-anchor-hash';
const Home: React.FC = () => {
return (
+
@@ -31,9 +32,9 @@ const Home: React.FC = () => {
/>
-
+
diff --git a/src/pages/payment-results/index.tsx b/src/pages/payment-results/index.tsx
new file mode 100644
index 00000000..fc69e5c0
--- /dev/null
+++ b/src/pages/payment-results/index.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import api from '@services/api';
+import successIcon from '@images/circle-ok-min.svg';
+import { textIfOrderPaid, textIfOrderNotPaid } from '@data/constants';
+import Payment from '../../components/payment';
+import OrderStatusTracker from '@components/order-status-tracker';
+import PaymentButton from '@components/payment-button';
+import Preloader from '@components/preloader';
+import failIcon from '@images/circle-not-ok.svg';
+import { useAuth } from '@hooks/use-auth';
+import styles from './payment-results.module.scss';
+
+type PaymentResultsProps = {
+ isPaid: boolean;
+};
+
+const PaymentResults: React.FC = ({ isPaid }) => {
+ const { isLoggedIn } = useAuth();
+ const [paimentInfo, setPaymentInfo] = useState({
+ order_id: '',
+ order_number: '',
+ stripe_session_id: '',
+ });
+ const textToShow = isPaid ? textIfOrderPaid : textIfOrderNotPaid;
+ const location = useLocation();
+ const paymentSessionIid = location.search.split('=')[1];
+
+ useEffect(() => {
+ const data = {
+ stripe_session_id: paymentSessionIid,
+ };
+
+ api.paymentCheck(data).then(setPaymentInfo);
+ }, [paymentSessionIid]);
+
+ if (!paimentInfo.order_number) return ;
+
+ return (
+
+ {isPaid ? (
+
+
+ Успешно!
+ {isLoggedIn ? (
+
+ Ваш платеж по заказу {paimentInfo.order_number} принят в обработку.
+ Отслеживать его вы можете в личном кабинете
+
+ ) : (
+
+ Ваш платеж по заказу {paimentInfo.order_number} принят в обработку. История
+ заказов доступна для зарегистрированных пользователей.
+
+ )}
+
+
+ ) : (
+
+
+
+ Ваш платеж по заказу {paimentInfo.order_number} отменён
+
+
+ Возможно это был временный сбой — просто попробуйте снова
+
+
+
+ )}
+
+ );
+};
+
+export default PaymentResults;
diff --git a/src/pages/payment-results/payment-results.module.scss b/src/pages/payment-results/payment-results.module.scss
new file mode 100644
index 00000000..1372d32d
--- /dev/null
+++ b/src/pages/payment-results/payment-results.module.scss
@@ -0,0 +1,54 @@
+@use '@scss/variables' as *;
+
+.image {
+ width: 66px;
+ height: 66px;
+}
+
+.container {
+ padding: 108px 128px 100px;
+ max-width: 1024px;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ font-family: $ubuntu-font;
+
+ @media screen and (width <= 768px) {
+ padding: 40px 20px;
+ }
+}
+
+.title {
+ color: $active-text-color;
+ font-size: 36px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: 140%;
+ text-align: center;
+ padding-top: 12px;
+ max-width: 450px;
+
+ @media screen and (width <= 768px) {
+ font-size: 24px;
+ max-width: 225px;
+ }
+}
+
+.info {
+ color: $active-text-color;
+ text-align: center;
+ font-size: 20px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 140%;
+ margin: 0;
+ padding: 20px 0 40px;
+ max-width: 450px;
+
+ @media screen and (width <= 768px) {
+ font-size: 14px;
+ max-width: 225px;
+ }
+}
diff --git a/src/pages/product/index.tsx b/src/pages/product/index.tsx
index 0c34519e..03e149ae 100644
--- a/src/pages/product/index.tsx
+++ b/src/pages/product/index.tsx
@@ -1,16 +1,19 @@
import React, { useEffect } from 'react';
import styles from './product.module.scss';
-import Button from '@components/Button';
+import Button from '@components/button';
import { useParams } from 'react-router';
import api from '@services/api.ts';
import Preloader from '@components/preloader';
import ReviewStar from '@images/review-star.svg';
import Breadcrumbs from '@components/breadcrumbs';
-import { Product as ProductType } from '@services/generated-api/data-contracts';
+import RatingsAndReviewsWidget from '@components/ratings-and-reviews-components/ratings-and-reviews-widget';
+import { Product as ProductType, Review } from '@services/generated-api/data-contracts';
import { useAuth } from '@hooks/use-auth';
import { usePopup } from '@hooks/use-popup';
import { useCart } from '@hooks/use-cart-context.ts';
import { useNavigate } from 'react-router-dom';
+import plural from '@components/ratings-and-reviews-components/utils/pluralizer';
+import { translateMeasureUnit } from '@utils/utils';
const Product: React.FC = () => {
const { cartData, updateCart, deleteCart } = useCart();
@@ -18,20 +21,23 @@ const Product: React.FC = () => {
const [isProductInCart, setIsProductInCart] = React.useState(false);
const [isLoaded, setIsLoaded] = React.useState(false);
const [productItem, setProductItem] = React.useState(null);
+ const [reviewsAmount, setReviewsAmount] = React.useState(0);
+ const [measureObj, setMeasureObj] = React.useState({ amount: 0, measureUnit: '' });
const { isLoggedIn } = useAuth();
const { handleOpenPopup } = usePopup();
const navigate = useNavigate();
const { id } = useParams();
- //Нужно с бэка получать в будщем:
- let newMeasureUnit = 'шт';
- if (productItem != null) {
- if (productItem.measure_unit === 'milliliters') {
- newMeasureUnit = 'мл';
- } else if (productItem.measure_unit === 'grams') {
- newMeasureUnit = 'гр';
- }
- }
+
+ useEffect(() => {
+ if (productItem === null) return;
+
+ const translatedMeasureObj = translateMeasureUnit(
+ productItem.measure_unit,
+ productItem.amount
+ );
+ setMeasureObj(translatedMeasureObj);
+ }, [productItem]);
useEffect(() => {
if (id !== undefined) {
@@ -41,11 +47,18 @@ const Product: React.FC = () => {
.then((data) => setProductItem(data))
.catch((error) => {
console.log(error);
- navigate('/упс');
+ navigate('/404');
});
+ api
+ .reviewsList(Number(id))
+ .then((res) => {
+ const amount = res.filter((item: Review) => !!item.text).length;
+ setReviewsAmount(amount);
+ })
+ .catch((err) => console.log(err));
} else {
console.log('ID is undefined');
- navigate('/упс');
+ navigate('/404');
}
}, [id, navigate]);
@@ -60,17 +73,13 @@ const Product: React.FC = () => {
}
}, [cartData, id]);
- useEffect(() => {
- window.scrollTo(0, 0);
- }, []);
-
const handleAddCartClick = () => {
if (isLoaded || !productItem) return;
if (id !== undefined) {
const numericId: number = parseInt(id, 10);
setIsInCart(isProductInCart);
if (!isProductInCart) {
- updateCart(numericId, 1);
+ updateCart([{ id: numericId, quantity: 1 }]);
setIsInCart(true);
} else {
deleteCart(numericId);
@@ -95,103 +104,115 @@ const Product: React.FC = () => {
}
return (
-
- {!productItem ? (
-
- ) : (
-
-
-
-
-
-
{productItem.name}
-
-
Арт. {productItem.id}
-
-
-
4.8
+
+
+ {!productItem ? (
+
+ ) : (
+
+
+
+
+
+
{productItem.name}
+
-
2 отзыва
+
+
+ {productItem.price} руб. / {measureObj.amount + measureObj.measureUnit}
+
+
+
+
-
- {productItem.price} руб. / {newMeasureUnit}
-
-
-
-
-
+
-
-
-
-
- {productItem.description}
-
-
-
Срок годности
-
5 суток
-
-
-
Производитель
+
- {`«${productItem.producer.producer_name}»`}
+ {productItem.description}
-
-
-
Энергетическая ценность (на 100гр.)
-
-
-
белки
-
{productItem.proteins}г
-
-
-
-
жиры
-
{productItem.fats}г
-
-
-
-
углеводы
-
{productItem.carbohydrates}г
-
-
-
-
ккал-ть
-
{productItem.kcal}г
+
+
Срок годности
+
5 суток
+
+
+
Производитель
+
+ {`«${productItem.producer.producer_name}»`}
+
+
+
+
Энергетическая ценность (на 100гр.)
+
+
+
белки
+
{productItem.proteins}г
+
+
+
+
жиры
+
{productItem.fats}г
+
+
+
+
углеводы
+
{productItem.carbohydrates}г
+
+
+
+
ккал-ть
+
{productItem.kcal}г
+
-
- )}
-
+ )}
+
+ {id &&
}
+
);
};
diff --git a/src/pages/product/product.module.scss b/src/pages/product/product.module.scss
index 5b7fdf81..d4c824a5 100644
--- a/src/pages/product/product.module.scss
+++ b/src/pages/product/product.module.scss
@@ -12,14 +12,13 @@
}
}
-.product {
+.container {
margin: 0 auto;
width: calc(100% - 129px * 2);
height: 100%;
display: flex;
- gap: 28px;
flex-direction: column;
- justify-content: center;
+ gap: 60px;
@media screen and (width < 768px) {
padding: 0;
@@ -27,6 +26,14 @@
}
}
+.product {
+ width: 100%;
+ display: flex;
+ gap: 28px;
+ flex-direction: column;
+ justify-content: center;
+}
+
.product__section {
opacity: 0;
transform: translateY(20px);
@@ -107,6 +114,7 @@
color: var(--hint, #2180b6);
font-size: 18px;
margin: 0;
+ text-decoration: none;
@media screen and (width < 768px) {
font-size: 13px;
diff --git a/src/pages/profile/profile-favorites/index.tsx b/src/pages/profile/profile-favorites/index.tsx
index 6a12cd1c..8287deb1 100644
--- a/src/pages/profile/profile-favorites/index.tsx
+++ b/src/pages/profile/profile-favorites/index.tsx
@@ -4,6 +4,7 @@ import api from '@services/api';
import ProductCard from '@components/product-card';
import ReturnBackButton from '@components/profile-components/return-back-button';
import { useProfile } from '@hooks/use-profile';
+import { useCart } from '@hooks/use-cart-context';
import type { Product } from '@services/generated-api/data-contracts';
import { Link } from 'react-router-dom';
@@ -14,11 +15,17 @@ export default function ProfileFavorites() {
error: '',
});
const [products, setProducts] = useState
>([]);
- const [checkboxesValues, setCheckboxesValue] = useState([]);
+ const [checkboxesValues, setCheckboxesValue] = useState>({});
const { isMobileScreen } = useProfile();
+ const { updateCart } = useCart();
useEffect(() => {
- products && setCheckboxesValue(products.map(() => false));
+ products &&
+ products.forEach((product) => {
+ setCheckboxesValue((prev) => {
+ return { ...prev, [product.id]: false };
+ });
+ });
}, [products]);
useEffect(() => {
@@ -46,18 +53,30 @@ export default function ProfileFavorites() {
});
}, []);
- const onCheckButton = (index: number) => {
+ const onCheckButton = (id: number) => {
return () => {
- setCheckboxesValue(
- checkboxesValues.map((value, i) => (i === index ? !value : value))
- );
+ setCheckboxesValue((prev) => {
+ return { ...prev, [id]: !prev[id] };
+ });
};
};
- const isChooseAll = checkboxesValues.every((el) => el) || !products.length;
+ const isChooseAll =
+ Object.values(checkboxesValues).every((el) => el) || !products.length;
const toggleAll = () => {
- setCheckboxesValue(checkboxesValues.map(() => (isChooseAll ? false : true)));
+ Object.keys(checkboxesValues).forEach((key) =>
+ setCheckboxesValue((prev) => ({ ...prev, [key]: isChooseAll ? false : true }))
+ );
+ };
+
+ const handleAddToCart = () => {
+ const dataToSend = Object.keys(checkboxesValues)
+ .filter((i) => checkboxesValues[i as unknown as keyof typeof checkboxesValues])
+ .map((item) => ({ id: Number(item), quantity: 1 }));
+ updateCart(dataToSend);
+
+ window.scroll(0, 0);
};
return (
@@ -71,7 +90,7 @@ export default function ProfileFavorites() {
{!productsLoadingStatus.inProcess && products.length ? (
- products.map((product, index) => (
+ products.map((product) => (
)}
- В корзину
+ i)}
+ >
+ В корзину
+
);
}
diff --git a/src/pages/profile/profile-orders/index.tsx b/src/pages/profile/profile-orders/index.tsx
index cc903b77..52e54461 100644
--- a/src/pages/profile/profile-orders/index.tsx
+++ b/src/pages/profile/profile-orders/index.tsx
@@ -1,46 +1,11 @@
import { useEffect, useState } from 'react';
+import api from '@services/api';
import ProfileOrder from '@components/profile-components/profile-order';
import ProfileOrderMobile from '@components/profile-components/profile-order-mobile';
import ReturnBackButton from '@components/profile-components/return-back-button';
import { useProfile } from '@hooks/use-profile';
+import type { CommonOrder } from '../utils/types';
import styles from './profile-orders.module.scss';
-import api from '@services/api';
-// import type { Product } from '@services/generated-api/data-contracts';
-// import { OrderList } from '@services/generated-api/data-contracts.ts';
-
-type OrderStatusType =
- | 'Ordered'
- | 'In processing'
- | 'Collecting'
- | 'Gathered'
- | 'In delivering'
- | 'Delivered'
- | 'Completed';
-
-type Product = {
- amount: number;
- final_price: number;
- id: number;
- measure_unit: string;
- name: string;
- quantity: string;
- photo: string;
- category: {
- category_name: string;
- category_slug: string;
- };
-};
-
-type CommonOrder = {
- id: number;
- order_number?: string;
- ordering_date?: string;
- total_price?: string;
- payment_method?: string;
- delivery_method?: string;
- status?: OrderStatusType;
- products: Array<{ product: Product; quantity: string }> | Product[];
-};
export default function ProfileOrders() {
const [isOpenDetails, setIsOpenDetails] = useState
();
diff --git a/src/pages/profile/utils/types.ts b/src/pages/profile/utils/types.ts
new file mode 100644
index 00000000..cbd5cd17
--- /dev/null
+++ b/src/pages/profile/utils/types.ts
@@ -0,0 +1,34 @@
+export type OrderStatusType =
+ | 'Ordered'
+ | 'In processing'
+ | 'Collecting'
+ | 'Gathered'
+ | 'In delivering'
+ | 'Delivered'
+ | 'Completed';
+
+export type Product = {
+ amount: number;
+ final_price: number;
+ id: number;
+ measure_unit: string;
+ name: string;
+ quantity: string;
+ photo: string;
+ category: {
+ category_name: string;
+ category_slug: string;
+ };
+};
+
+export type CommonOrder = {
+ id: number;
+ is_paid: boolean;
+ order_number?: string;
+ ordering_date?: string;
+ total_price?: string;
+ payment_method: string;
+ delivery_method: string;
+ status?: OrderStatusType;
+ products: Array<{ product: Product; quantity: number }> | Product[];
+};
diff --git a/src/pages/profile/utils/utils.ts b/src/pages/profile/utils/utils.ts
new file mode 100644
index 00000000..0deddb57
--- /dev/null
+++ b/src/pages/profile/utils/utils.ts
@@ -0,0 +1,23 @@
+export const getPaymentMethodRu = (paymentMethod: string) => {
+ switch (paymentMethod) {
+ case 'Payment at the point of delivery':
+ return 'при самовывозе';
+ case 'In getting by cash':
+ return 'курьеру';
+ case 'Online':
+ return 'онлайн';
+ default:
+ return '';
+ }
+};
+
+export const getDeliveryMethodRu = (deliveryMethod: string) => {
+ switch (deliveryMethod) {
+ case 'Point of delivery':
+ return 'самовывоз';
+ case 'By courier':
+ return 'курьером';
+ default:
+ return '';
+ }
+};
diff --git a/src/pages/recipe/index.tsx b/src/pages/recipe/index.tsx
index bb7266fc..ec64ae67 100644
--- a/src/pages/recipe/index.tsx
+++ b/src/pages/recipe/index.tsx
@@ -1,53 +1,120 @@
-import React, { useState } from 'react';
-import styles from './recipe.module.scss';
-import Breadcrumbs from '@components/breadcrumbs';
-import IngredientsList from '@components/recipes-components/ingredients-list';
-import { declOfNum } from '@utils/utils';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useNavigate, useParams } from 'react-router';
import clsx from 'clsx';
-import api from '@services/api.ts';
+
+import Breadcrumbs from '@components/breadcrumbs';
import Preloader from '@components/preloader';
-import { useEffect } from 'react';
-import { useParams } from 'react-router';
import RecipeInfo from '@components/recipes-components/recipe-info';
-import { usePopup } from '@hooks/use-popup';
+import IngredientsList from '@components/recipes-components/ingredients-list';
import PopupRecipe from '@components/popups/popup-recipe';
-type ReceipeIngredientInfoProps = {
- id: number;
- name: string;
- measure_unit: string;
- quantity: number;
- ingredient_photo: string;
- photo?: string;
- amount?: number;
- price?: number;
-};
-
-type ReceipeInfoProps = {
- id: number;
- author: number;
- name: string;
- text: string;
- image: string;
- ingredients: ReceipeIngredientInfoProps[];
- total_ingredients?: string;
- recipe_nutrients?: {
- proteins: number;
- fats: number;
- carbonhydrates: number;
- kcal: number;
- };
- cooking_time: number;
-};
+import type {
+ ReceipeIngredient,
+ ReceipeInfoProps,
+} from '@components/recipes-components/types';
+import type { Product } from '@services/generated-api/data-contracts';
+import api from '@services/api.ts';
+import { declOfNum } from '@utils/utils';
+import { translateMeasureUnit } from '@utils/utils';
+import { useCart } from '@hooks/use-cart-context';
+import { usePopup } from '@hooks/use-popup';
+import styles from './recipe.module.scss';
const Recipe: React.FC = () => {
const { id } = useParams();
- const { handleOpenPopup } = usePopup();
+ const { handleOpenPopup, handleClosePopup } = usePopup();
+ const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(true);
const [recipeInfo, setRecipeInfo] = useState(Object);
const [recipeByLines, setRecipeByLines] = useState(['']);
const [numeralizeWord, setNumeralizeWord] = useState('');
+ const [productsIdAndCategories, setProductsIdAndCategories] = useState<
+ (string | number | undefined)[][]
+ >([[]]);
+ const { reset } = useCart();
+ const [recipeNutrients, setRecipeNutrients] = useState({
+ proteins: 0,
+ fats: 0,
+ carbonhydrates: 0,
+ kcal: 0,
+ });
+ const [ingredients, setIngredients] = useState([
+ {
+ amount: 0,
+ final_price: 0,
+ id: 0,
+ ingredient_photo: '',
+ measure_unit: '',
+ name: '',
+ need_to_buy: 0,
+ quantity_in_recipe: 0,
+ quantity_in_recipe_measure: '',
+ },
+ ]);
+
+ const updateIngredientMeasureUnits = (ingredients: ReceipeIngredient[]) => {
+ return ingredients.map((ingredient) => {
+ const ingredientMeasureUnit = ingredient.measure_unit;
+ const { measureUnit, amount } = translateMeasureUnit(
+ ingredientMeasureUnit,
+ ingredient.amount
+ );
+
+ const { measureUnit: newMeasureUnit, amount: newAmount } = translateMeasureUnit(
+ ingredientMeasureUnit,
+ ingredient.quantity_in_recipe
+ );
+ return {
+ ...ingredient,
+ amount,
+ measure_unit: measureUnit,
+ quantity_in_recipe: newAmount,
+ quantity_in_recipe_measure: `${newAmount} ${newMeasureUnit}`,
+ };
+ });
+ };
+
+ const getRecipeByLines = (recipeText: string) => {
+ return recipeText.split('\n');
+ };
+ const getNumeralizeWord = (cookingTime: number) => {
+ return declOfNum(cookingTime, ['минута', 'минуты', 'минут']);
+ };
+ const extractRecipeNutrients = (recipe: ReceipeInfoProps) => {
+ return {
+ proteins: recipe.proteins,
+ fats: recipe.fats,
+ carbonhydrates: recipe.carbohydrates,
+ kcal: recipe.kcal,
+ };
+ };
+
+ const fetchProducts = useCallback(async (products: ReceipeIngredient[]) => {
+ const updatedProductsPromises = await products.map((product) => {
+ return api.productsRead(product.id);
+ });
+ const updatedProducts: Product[] = await Promise.all(updatedProductsPromises);
+
+ return updatedProducts.map((product) => {
+ const categoryName = product.category?.category_slug;
+ return [product.id, categoryName];
+ });
+ }, []);
+
+ const handleClick = (
+ id: number,
+ idAndCategories: (string | number | undefined)[][] = productsIdAndCategories
+ ) => {
+ for (const idAndCategory of idAndCategories) {
+ if (idAndCategory.includes(id)) {
+ handleClosePopup('openPopupRecipe');
+ const id = idAndCategory[0];
+ const category = idAndCategory[1];
+ return navigate(`/catalog/${category}/${id}`);
+ }
+ }
+ };
useEffect(() => {
if (!id) {
@@ -55,38 +122,33 @@ const Recipe: React.FC = () => {
}
const recipeId: number = parseInt(id, 10);
+
const fetchReceiptAndProducts = async () => {
- const data = await api.getRecipeById(recipeId);
- setRecipeInfo(data);
- setRecipeByLines(data.text.split('\n'));
- setNumeralizeWord(declOfNum(data.cooking_time, ['минута', 'минуты', 'минут']));
-
- const promises = data.ingredients.map((ingredient: ReceipeIngredientInfoProps) => {
- return api.productsRead(ingredient.id);
- });
-
- const newProducts = await Promise.all(promises);
- const filteredProducts = newProducts.filter((product) => product !== null);
-
- setRecipeInfo((prevReceipeInfo) => {
- filteredProducts.map((product) => {
- const index = prevReceipeInfo.ingredients.findIndex((i) => i.id == product.id);
- if (index === -1) {
- return;
- }
-
- prevReceipeInfo.ingredients[index].photo = product.photo;
- prevReceipeInfo.ingredients[index].amount = product.amount;
- prevReceipeInfo.ingredients[index].price = product.price;
- });
-
- return prevReceipeInfo;
- });
+ const recipe: ReceipeInfoProps = await api.getRecipeById(recipeId);
+
+ const recipeByLines = getRecipeByLines(recipe.text);
+ const numeralizeWord = getNumeralizeWord(recipe.cooking_time);
+ const updatedIngredients = updateIngredientMeasureUnits(recipe.ingredients);
+ const nutrients = extractRecipeNutrients(recipe);
+ const fetchedProductsIdAndCategories = await fetchProducts(updatedIngredients);
+
+ if (fetchedProductsIdAndCategories !== undefined) {
+ setProductsIdAndCategories(fetchedProductsIdAndCategories);
+ }
+ setRecipeInfo(recipe);
+ setRecipeByLines(recipeByLines);
+ setNumeralizeWord(numeralizeWord);
+ setIngredients(updatedIngredients);
+ setRecipeNutrients(nutrients);
};
fetchReceiptAndProducts().finally(() => setIsLoading(false));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [fetchProducts, id]);
+
+ const handleAddToCart = () => {
+ reset();
+ handleOpenPopup('openPopupRecipe');
+ };
return (
@@ -105,12 +167,12 @@ const Recipe: React.FC = () => {
{`${recipeInfo.cooking_time} ${numeralizeWord}`}
-
+
handleOpenPopup('openPopupRecipe')}
+ onClick={handleAddToCart}
>
Добавить все в корзину
@@ -122,14 +184,15 @@ const Recipe: React.FC = () => {
Инструкция приготовления
- {recipeByLines.map((line, index) => (
+ {recipeByLines.slice(2).map((line, index) => (
{line}
@@ -139,7 +202,7 @@ const Recipe: React.FC = () => {
)}
-
+
);
};
diff --git a/src/pages/recipe/recipe.module.scss b/src/pages/recipe/recipe.module.scss
index 2a66d6d5..b4feb160 100644
--- a/src/pages/recipe/recipe.module.scss
+++ b/src/pages/recipe/recipe.module.scss
@@ -90,10 +90,6 @@
margin-bottom: 25px;
}
- &__item:first-child {
- font-weight: 500;
- }
-
&__item:last-child {
margin: 0;
}
diff --git a/src/pages/shopping-cart/index.tsx b/src/pages/shopping-cart/index.tsx
index e73cfa1c..c644555d 100644
--- a/src/pages/shopping-cart/index.tsx
+++ b/src/pages/shopping-cart/index.tsx
@@ -11,8 +11,8 @@ import Preloader from '@components/preloader';
import { Product } from '@services/generated-api/data-contracts';
const ShoppingCart: React.FC = () => {
- const { cartData, loading } = useCart();
- const [activeButton, setActiveButton] = React.useState('shipment');
+ const { cartData, clearCart, loading } = useCart();
+ const [deliveryMethod, setDeliveryMethod] = React.useState('By courier');
const navigate = useNavigate();
const [promotionProducts, setPromotionProducts] = useState([]);
@@ -41,19 +41,21 @@ const ShoppingCart: React.FC = () => {
}, []);
const handleOrderTypeClick = (type: string) => {
- setActiveButton(type);
+ setDeliveryMethod(type);
};
const handleSubmitOrderClick = () => {
- const typeToSend = activeButton;
-
navigate('/cart/order', {
state: {
- orderType: typeToSend,
+ orderType: deliveryMethod,
},
});
};
+ const handleClearCart = () => {
+ clearCart();
+ };
+
return (
@@ -66,7 +68,13 @@ const ShoppingCart: React.FC = () => {
{`${cartData.count_of_products} товаров`}
- Очистить корзину
+
+ Очистить корзину
+
{cartData.products.length > 0 ? (
@@ -79,16 +87,19 @@ const ShoppingCart: React.FC = () => {
Итого
- {cartData.total_price ? `${cartData.total_price.toFixed(2)} руб.` : 'N/A'}
+ {cartData.total_price ? `${cartData.total_price.toFixed(2)} руб.` : '0'}
{
- handleOrderTypeClick('shipment');
+ handleOrderTypeClick('By courier');
}}
>
{
{
- handleOrderTypeClick('pickup');
+ handleOrderTypeClick('Point of delivery');
}}
>
Самовывоз
-
+
)}
diff --git a/src/pages/shopping-cart/shopping-cart.module.scss b/src/pages/shopping-cart/shopping-cart.module.scss
index 931064c2..cf55b813 100644
--- a/src/pages/shopping-cart/shopping-cart.module.scss
+++ b/src/pages/shopping-cart/shopping-cart.module.scss
@@ -259,25 +259,24 @@
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: Ubuntu;
font-size: 18px;
- color: #285718;
- font-style: normal;
+ color: $green-primary-700;
font-weight: 400;
line-height: 140%;
cursor: pointer;
- :hover {
- opacity: 0.5;
- transition: opacity 0.5s ease-in-out;
+ &:hover {
+ color: $accent-color-bright-green;
+ transition: color 0.2s ease-in-out;
}
- .products__btn_unactive {
- color: #e5e5e5;
+ &:disabled {
+ color: $gray-button;
cursor: default;
}
@media screen and (width <= 768px) {
font-size: 13px;
- color: #285718;
+ color: $green-primary-700;
}
}
diff --git a/src/services/api.ts b/src/services/api.ts
index ae81f660..25364268 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -18,6 +18,9 @@ import type {
Subcategory,
Tag,
OrderPostAdd,
+ ReviewCreate,
+ ReviewUpdate,
+ Payment,
} from './generated-api/data-contracts';
import { BACKEND_URL } from '@data/constants.ts';
import Cookies from 'js-cookie';
@@ -31,7 +34,7 @@ class Api {
_checkResponse(res: Response) {
if (res.ok) {
- if (res.status === 204) return res;
+ if (res.status === 204 || res.status === 205) return res;
return res.json();
}
@@ -232,6 +235,7 @@ class Api {
});
}
+ /* ------------------------------- Order ------------------------------- */
usersOrderList() {
return this._request(`order/`, {
method: 'GET',
@@ -258,6 +262,20 @@ class Api {
});
}
+ usersOrderPay(id: number) {
+ const headers: HeadersInit = {
+ 'Content-Type': 'application/json',
+ };
+ const token = Cookies.get('token');
+ if (token) {
+ headers.Authorization = `Token ${token}`;
+ }
+ return this._request(`order/${id}/pay/`, {
+ method: 'POST',
+ headers,
+ });
+ }
+
usersOrderRead(userId: string, id: number) {
return this._request(`users/${userId}/order/${id}/`, {
method: 'GET',
@@ -270,6 +288,7 @@ class Api {
});
}
+ /* ---------------------------- ShoppingCart ---------------------------- */
usersShoppingCartList() {
return this._request(`shopping_cart/`, {
method: 'GET',
@@ -301,20 +320,13 @@ class Api {
});
}
- usersShoppingCartRead(userId: string, id: number) {
- return this._request(`users/${userId}/shopping_cart/${id}/`, {
- method: 'GET',
- });
- }
-
- usersShoppingCartPartialUpdate(
- userId: string,
- id: number,
- data: ShoppingCartPostUpdateDelete
- ) {
- return this._request(`users/${userId}/shopping_cart/${id}/`, {
- method: 'PATCH',
- body: JSON.stringify(data),
+ usersShoppingCartDeleteAll() {
+ return this._request('shopping_cart/remove_all/', {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
});
}
@@ -387,6 +399,16 @@ class Api {
});
}
+ productsOrderCheck(id: number) {
+ return this._request(`products/${id}/order-user-check/`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Token ${Cookies.get('token')}`,
+ },
+ });
+ }
+
/* -------------------------- FavoriteProducts -------------------------- */
favoriteProductsList() {
return this._request('favorite-products/', {
@@ -621,6 +643,46 @@ class Api {
method: 'GET',
});
}
+
+ /* ------------------------ Ratings and Reviews -------------------------- */
+ reviewsCreate(productId: number, data: ReviewCreate) {
+ return this._request(`products/${productId}/reviews/`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Token ${Cookies.get('token')}`,
+ },
+ body: JSON.stringify(data),
+ });
+ }
+
+ reviewsUpdate(productId: number, reviewId: number, data: ReviewUpdate) {
+ return this._request(`products/${productId}/reviews/${reviewId}/`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Token ${Cookies.get('token')}`,
+ },
+ body: JSON.stringify(data),
+ });
+ }
+
+ reviewsList(productId: number) {
+ return this._request(`products/${productId}/reviews/`, {
+ method: 'GET',
+ });
+ }
+
+ /* ----------------------------- Payment ------------------------------- */
+ paymentCheck(data: Payment) {
+ return this._request('order/successful_pay/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+ }
}
const api = new Api(BACKEND_URL);
diff --git a/src/services/generated-api/data-contracts.ts b/src/services/generated-api/data-contracts.ts
index 36b5a8f8..6516c134 100644
--- a/src/services/generated-api/data-contracts.ts
+++ b/src/services/generated-api/data-contracts.ts
@@ -30,6 +30,7 @@ export interface Category {
* @maxLength 100
*/
name: string;
+ image: string;
/**
* Slug
* @format slug
@@ -38,8 +39,8 @@ export interface Category {
*/
slug?: string;
subcategories?: SubcategoryLight[];
- /** Top three products */
- top_three_products?: string;
+ /** Top products */
+ top_products: string;
}
export interface CategoryCreate {
@@ -333,14 +334,14 @@ export interface Product {
discontinued?: boolean;
producer: ProducerLight;
/** Measure unit */
- measure_unit?: 'grams' | 'milliliters' | 'items';
+ measure_unit: 'grams' | 'milliliters' | 'items';
/**
* Amount
* Number of grams, milliliters or items
* @min 0
* @max 32767
*/
- amount?: number;
+ amount: number;
/**
* Price
* Price per one product unit
@@ -357,6 +358,7 @@ export interface Product {
* @format uri
*/
photo?: string;
+ rating?: number;
components: ComponentLight[];
/**
* Kcal
@@ -1005,9 +1007,9 @@ export interface OrderList {
| 'Delivered'
| 'Completed';
/** Payment Method */
- payment_method?: 'Payment at the point of delivery' | 'In getting by cash';
+ payment_method?: 'Payment at the point of delivery' | 'In getting by cash' | 'Online';
/** Is paid */
- is_paid?: boolean;
+ is_paid: boolean;
/** Delivery Method */
delivery_method?: 'Point of delivery' | 'By courier';
/** Address */
@@ -1037,7 +1039,7 @@ export interface OrderList {
export interface OrderPostDelete {
/** Payment Method */
- payment_method?: 'Payment at the point of delivery' | 'In getting by cash';
+ payment_method?: 'Payment at the point of delivery' | 'In getting by cash' | 'Online';
/** Delivery Method */
delivery_method?: 'Point of delivery' | 'By courier';
/** Delivery Point */
@@ -1055,7 +1057,7 @@ export interface OrderPostDelete {
}
export interface OrderPostAdd extends OrderPostDelete {
- user_data: {
+ user_data?: {
first_name: string;
last_name: string;
phone_number: string;
@@ -1063,3 +1065,71 @@ export interface OrderPostAdd extends OrderPostDelete {
};
address?: number;
}
+
+export interface ReviewCreate {
+ /** Rating Score
+ * @min 1
+ * @max 5
+ */
+ score: number;
+
+ /** Review Text */
+ text?: string;
+}
+export interface ReviewUpdate {
+ /** Rating Score
+ * @min 1
+ * @max 5
+ */
+ score?: number;
+
+ /** ReviewText */
+ text?: string;
+}
+
+export interface Review {
+ /** ID */
+ id: number;
+
+ /** Author */
+ author: {
+ /** ID of author */
+ id: number;
+
+ /** Name of author
+ * @pattern ^[\w.@+-]+$
+ * */
+ username: string;
+ };
+
+ /** Product */
+ product: string;
+
+ /** Rating Score
+ * @min 1
+ * @max 5
+ */
+ score: number;
+
+ /**
+ * Publication date
+ * @format date-time
+ */
+ pub_date: string;
+
+ /** Was Edited */
+ was_edited: boolean;
+
+ /** Review Text */
+ text: string;
+}
+
+export interface OrderCheck {
+ product: number;
+ user: number;
+ ordered: boolean;
+}
+
+export type Payment = {
+ stripe_session_id: string;
+};
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index d4fd39f6..f6830849 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -18,24 +18,53 @@ export function declOfNum(n: number, titles: [string, string, string]) {
];
}
-export function toMeasureUnit(
- measureUnit: string | undefined | null,
- weight: number | null
-) {
- let newMeasureUnit = 'шт';
- let newWeight = weight;
+type translationsType = {
+ grams: {
+ singular: 'гр';
+ plural: 'кг';
+ };
+ milliliters: {
+ singular: 'мл';
+ plural: 'л';
+ };
+ items: {
+ singular: 'шт';
+ plural: 'шт';
+ };
+};
- if (newWeight != null) {
- if (measureUnit === 'milliliters') {
- newMeasureUnit = 'мл';
- } else if (measureUnit === 'grams') {
- newMeasureUnit = 'гр';
- if (newWeight > 999) {
- newMeasureUnit = 'кг';
- newWeight = newWeight / 1000;
- }
- }
- }
+export const translations: translationsType = {
+ grams: { singular: 'гр', plural: 'кг' },
+ milliliters: { singular: 'мл', plural: 'л' },
+ items: { singular: 'шт', plural: 'шт' },
+};
- return { newMeasureUnit, newWeight };
-}
+export const translateMeasureUnit = (measureUnit: string, amount: number) => {
+ const translatedMeasureObj = { measureUnit, amount };
+
+ switch (true) {
+ case !measureUnit || !amount:
+ translatedMeasureObj.measureUnit = translations.items.singular;
+ translatedMeasureObj.amount = 1;
+ return translatedMeasureObj;
+ case amount > 499 && measureUnit === 'grams':
+ translatedMeasureObj.measureUnit = translations.grams.plural;
+ translatedMeasureObj.amount = amount / 1000;
+ return translatedMeasureObj;
+ case amount > 499 && measureUnit === 'milliliters':
+ translatedMeasureObj.measureUnit = translations.milliliters.plural;
+ translatedMeasureObj.amount = amount / 1000;
+ return translatedMeasureObj;
+ case measureUnit === 'grams':
+ translatedMeasureObj.measureUnit = translations.grams.singular;
+ return translatedMeasureObj;
+ case measureUnit === 'milliliters':
+ translatedMeasureObj.measureUnit = translations.milliliters.singular;
+ return translatedMeasureObj;
+ case measureUnit === 'items':
+ translatedMeasureObj.measureUnit = translations.items.singular;
+ return translatedMeasureObj;
+ default:
+ return translatedMeasureObj;
+ }
+};