diff --git a/package-lock.json b/package-lock.json index bf20ae2..5dee8d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1873,6 +1873,39 @@ } } }, + "@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" + }, + "@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" + }, + "@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" + }, + "@reduxjs/toolkit": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.6.2.tgz", + "integrity": "sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA==", + "requires": { + "immer": "^9.0.6", + "redux": "^4.1.0", + "redux-thunk": "^2.3.0", + "reselect": "^4.0.0" + }, + "dependencies": { + "immer": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.7.tgz", + "integrity": "sha512-KGllzpbamZDvOIxnmJ0jI840g7Oikx58lBPWV0hUh7dtAyZpFqqrBZdKka5GlTwMTZ1Tjc/bKKW4VSFAt6BqMA==" + } + } + }, "@rollup/plugin-node-resolve": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz", @@ -2285,6 +2318,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", @@ -2383,6 +2425,17 @@ "@types/react": "*" } }, + "@types/react-redux": { + "version": "7.1.20", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz", + "integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -5095,6 +5148,16 @@ "path-type": "^4.0.0" } }, + "dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "requires": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, "dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -7066,6 +7129,14 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -12132,6 +12203,26 @@ } } }, + "react-dnd": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", + "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", + "requires": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", + "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", + "requires": { + "dnd-core": "14.0.1" + } + }, "react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -12152,6 +12243,26 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } + }, "react-refresh": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", @@ -12298,6 +12409,19 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "redux-thunk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", + "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==" + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12502,6 +12626,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, + "reselect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.5.tgz", + "integrity": "sha512-uVdlz8J7OO+ASpBYoz1Zypgx0KasCY20H+N8JD13oUMtPvSHQuscrHop4KbXrbsBcdB9Ds7lVK7eRkBIfO43vQ==" + }, "resolve": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz", diff --git a/package.json b/package.json index 22e5f86..d86fea3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@reduxjs/toolkit": "^1.6.2", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^12.8.3", @@ -12,8 +13,13 @@ "@types/react-dom": "^17.0.10", "@ya.praktikum/react-developer-burger-ui-components": "^1.12.0", "react": "^17.0.2", + "react-dnd": "^14.0.4", + "react-dnd-html5-backend": "^14.0.2", "react-dom": "^17.0.2", + "react-redux": "^7.2.6", "react-scripts": "4.0.3", + "redux": "^4.1.2", + "redux-thunk": "^2.4.1", "typescript": "^4.4.4", "web-vitals": "^1.1.2" }, diff --git a/src/components/app/app.jsx b/src/components/app/app.jsx index 8c8bd1e..4626ecc 100644 --- a/src/components/app/app.jsx +++ b/src/components/app/app.jsx @@ -1,11 +1,13 @@ -import { useReducer, useEffect } from 'react'; +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; import s from './app.module.css'; -import { AppContext } from '../../services/app-context'; -import { ENDPOINT, ApiRoute } from '../../utils/constants'; -import appReducer, { - FETCH_INGREDIENTS, - FETCH_INGREDIENTS_ERROR, -} from '../../services/modules/app'; +import { + getIngredientsState, + fetchAllIngredients, +} from '../../services/ducks/ingredients'; +import { getAppState } from '../../services/ducks/app'; // Components import AppHeader from '../app-header/app-header'; @@ -14,66 +16,31 @@ import BurgerConstructor from '../burger-constructor/burger-constructor'; import Loader from '../loader/loader'; import Error from '../error/error'; -// HOC -import withModal from '../hocs/with-modal'; - -const WithModalBurgerConstructor = withModal(BurgerConstructor); -const WithModalBurgerIngredients = withModal(BurgerIngredients); - -const initialAppState = { - ingredients: [], - ingredient: null, - burgerData: { - bunIngredient: null, - mainIngredients: [], - }, - orderNumber: null, - totalPrice: 0, - modalType: null, - loading: true, - error: false, -}; - const App = () => { - const [state, dispatch] = useReducer(appReducer, initialAppState); - const { loading, error } = state; + const dispatch = useDispatch(); + const { isLoading } = useSelector(getIngredientsState); + const { isError } = useSelector(getAppState); useEffect(() => { - const getData = async () => { - try { - const response = await fetch(`${ENDPOINT}/${ApiRoute.INGREDIENTS}`); - - if (response?.ok) { - const { data: ingredients } = await response.json(); - return dispatch({ - type: FETCH_INGREDIENTS, - payload: ingredients, - }); - } - - throw new Error(); - } catch (err) { - dispatch({ - type: FETCH_INGREDIENTS_ERROR, - }); - } + const fetchIngredients = async () => { + await dispatch(fetchAllIngredients()); }; - getData(); - }, []); + fetchIngredients(); + }, [dispatch]); - if (loading) return ; + if (isLoading) return ; return ( <> - {error ? ( + {isError ? ( Произошла ошибка при загрузке данных... ) : (
- - - - + + + +
)} diff --git a/src/components/bun-ingredient/bun-ingredient.jsx b/src/components/bun-ingredient/bun-ingredient.jsx new file mode 100644 index 0000000..54fa6a2 --- /dev/null +++ b/src/components/bun-ingredient/bun-ingredient.jsx @@ -0,0 +1,37 @@ +import { memo } from 'react'; +import pt from 'prop-types'; +import { PropValidator } from '../../utils/prop-validator'; +import { ConstructorElement } from '@ya.praktikum/react-developer-burger-ui-components'; +import { BunPosition } from '../../utils/constants'; + +// Components +import NoIngredient from '../no-ingredient/no-ingredient'; + +const BunIngredient = ({ ingredient, position }) => { + const positionName = position === BunPosition.TOP ? 'верх' : 'низ'; + + if (!ingredient) + return Выберите булку ({positionName}); + + const { name, price, image_mobile } = ingredient; + + return ( + + ); +}; + +BunIngredient.propTypes = { + ingredient: pt.oneOfType([ + PropValidator.INGREDIENT.isRequired, + pt.oneOf([null]).isRequired, + ]), + position: pt.string.isRequired, +}; + +export default memo(BunIngredient); diff --git a/src/components/burger-constructor/burger-constructor.jsx b/src/components/burger-constructor/burger-constructor.jsx index 230ac57..6246c78 100644 --- a/src/components/burger-constructor/burger-constructor.jsx +++ b/src/components/burger-constructor/burger-constructor.jsx @@ -1,149 +1,95 @@ -import { useContext, useEffect } from 'react'; -import { - Button, - ConstructorElement, - CurrencyIcon, - DragIcon, - BurgerIcon, -} from '@ya.praktikum/react-developer-burger-ui-components'; +import { useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useDrop } from 'react-dnd'; import s from './burger-constructor.module.css'; -import { AppContext } from '../../services/app-context'; -import { ENDPOINT, ApiRoute, ModalType } from '../../utils/constants'; import { - SET_ORDER_NUMBER, - SEND_ORDER_ERROR, - DELETE_INGREDIENT, - SET_TOTAL_PRICE, -} from '../../services/modules/app'; + getBurgerIngredients, + getTotalPrice, + addIngredient, + resetBurgerIngredients, +} from '../../services/ducks/burger-ingredients'; +import { + sendOrder, + resetOrder, + getOrderState, +} from '../../services/ducks/order'; +import { BunPosition } from '../../utils/constants'; // Components -import NoIngredient from '../no-ingredient/no-ingredient'; +import BunIngredient from '../bun-ingredient/bun-ingredient'; +import MainIngredientsList from '../main-ingredients-list/main-ingredients-list'; +import SubmitSection from '../submit-section/submit-section'; +import NoIngredients from '../no-ingredients/no-ingredients'; +import Modal from '../modal/modal'; +import OrderDetails from '../order-details/order-details'; const BurgerConstructor = () => { - const { - burgerData: { bunIngredient, mainIngredients }, - totalPrice, - dispatch, - } = useContext(AppContext); + const dispatch = useDispatch(); + const [{ isHover }, dropRef] = useDrop({ + accept: 'ingredient', + drop(ingredient) { + handleDrop(ingredient); + }, + collect: (monitor) => ({ + isHover: monitor.isOver(), + }), + }); + const { bunIngredient, mainIngredients } = useSelector(getBurgerIngredients); + const { orderNumber, isLoading } = useSelector(getOrderState); + const totalPrice = useSelector(getTotalPrice); const isIngredientsExist = bunIngredient || mainIngredients.length > 0; - const handleSendOrder = async () => { - const ingredients = [bunIngredient, ...mainIngredients].map( + const handleSendOrder = useCallback(async () => { + const ingredientsIds = [bunIngredient, ...mainIngredients].map( ({ _id }) => _id ); + await dispatch(sendOrder(ingredientsIds)); + }, [bunIngredient, mainIngredients, dispatch]); - try { - const response = await fetch(`${ENDPOINT}/${ApiRoute.ORDERS}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ingredients }), - }); - - if (response?.ok) { - const { order } = await response.json(); - return dispatch({ - type: SET_ORDER_NUMBER, - payload: { orderNumber: order.number, modalType: ModalType.ORDER }, - }); - } - - throw new Error(); - } catch (err) { - dispatch({ - type: SEND_ORDER_ERROR, - }); - } - }; - - const handleDeleteIngredient = (ingredientIndex) => { - const newMainIngredients = mainIngredients.filter( - (_, index) => index !== ingredientIndex - ); - dispatch({ type: DELETE_INGREDIENT, payload: newMainIngredients }); - }; - - useEffect(() => { - const bunPrice = bunIngredient?.price * 2 || 0; - - const totalPrice = - mainIngredients.reduce( - (totalPrice, { price }) => (totalPrice += price), - 0 - ) + bunPrice; + const handleDrop = useCallback( + (ingredient) => { + dispatch(addIngredient(ingredient)); + }, + [dispatch] + ); - dispatch({ - type: SET_TOTAL_PRICE, - payload: totalPrice, - }); - }, [bunIngredient, mainIngredients, dispatch]); + const handleCloseModal = useCallback(() => { + dispatch(resetOrder()); + dispatch(resetBurgerIngredients()); + }, [dispatch]); return ( -
+
{!isIngredientsExist ? ( -
- -

- Выберите ингредиенты для вашего бургера -

-
+ ) : ( <> -
- {bunIngredient ? ( - - ) : ( - Выберите булку (верх) - )} -
    - {mainIngredients.length ? ( - mainIngredients.map(({ name, price, image_mobile }, index) => ( -
  • - - handleDeleteIngredient(index)} - /> -
  • - )) - ) : ( - Выберите основные ингредиенты - )} -
- {bunIngredient ? ( - - ) : ( - Выберите булку (низ) - )} -
-
-

- {totalPrice}  - -

- +
+ + +
+ + {orderNumber && ( + + + + )} )}
diff --git a/src/components/burger-constructor/burger-constructor.module.css b/src/components/burger-constructor/burger-constructor.module.css index 8cd6839..03aa24f 100644 --- a/src/components/burger-constructor/burger-constructor.module.css +++ b/src/components/burger-constructor/burger-constructor.module.css @@ -4,6 +4,11 @@ width: 100%; } +.hoverBurgerConstructorSection { + composes: burgerConstructorSection; + outline: 3px dashed rgba(255, 255, 255, 0.4); +} + .ingredientsContainer { display: flex; flex-flow: column wrap; @@ -11,88 +16,7 @@ gap: 16px; } -.noIngredientsBlock { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 100%; - display: flex; - flex-flow: column; - align-items: center; -} - -.noIngredientsBlock svg { - width: 30%; - height: 30%; - margin-bottom: 40px; -} - .ingredientsContainer > div { max-width: 536px; width: 100%; } - -.ingredientsList { - display: flex; - align-items: center; - flex-flow: column; - gap: 16px; - max-height: 465px; - overflow-y: auto; -} - -.ingredientsList::-webkit-scrollbar { - width: 8px; -} - -.ingredientsList::-webkit-scrollbar-track { - background: #2f2f37; -} - -.ingredientsList::-webkit-scrollbar-thumb { - background: #8585ad; -} - -.ingredientItem { - display: flex; - align-items: center; - margin-right: 25px; -} - -.ingredientItem > svg { - margin-right: 5px; -} - -.ingredientItem > div { - max-width: 536px; - width: 536px; -} - -.submitSection { - display: flex; - justify-content: flex-end; - align-items: center; - margin-right: 30px; -} - -.submitSection button { - cursor: pointer; -} - -.submitSection button:disabled { - box-shadow: none; - filter: none; - opacity: 0.2; - cursor: default; -} - -.totalPrice { - display: flex; - align-items: center; -} - -.totalPrice > svg { - width: 33px; - height: 33px; -} diff --git a/src/components/burger-ingredients/burger-ingredients.jsx b/src/components/burger-ingredients/burger-ingredients.jsx index 4174bf1..d9ee707 100644 --- a/src/components/burger-ingredients/burger-ingredients.jsx +++ b/src/components/burger-ingredients/burger-ingredients.jsx @@ -1,117 +1,51 @@ -import { useState, useCallback, useMemo, useContext, memo } from 'react'; -import { Tab } from '@ya.praktikum/react-developer-burger-ui-components'; +import { useState, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import s from './burger-ingredients.module.css'; -import { AppContext } from '../../services/app-context'; -import { ModalType } from '../../utils/constants'; +import { IngredientType } from '../../utils/constants'; import { - SET_INGREDIENT, - ADD_BUN_INGREDIENT, - ADD_MAIN_INGREDIENT, -} from '../../services/modules/app'; + getCurrentIngredient, + setCurrentIngredient, +} from '../../services/ducks/current-ingredient'; // Components -import IngredientsList from '../ingredients-list/ingredients-list'; - -const IngredientType = { - BUN: 'bun', - SAUCE: 'sauce', - MAIN: 'main', -}; - -const tabItems = [ - { - name: 'Булки', - type: IngredientType.BUN, - }, - { - name: 'Соусы', - type: IngredientType.SAUCE, - }, - { - name: 'Начинки', - type: IngredientType.MAIN, - }, -]; +import IngredientsTabs from '../ingredients-tabs/ingredients-tabs'; +import IngredientsListContainer from '../ingredients-list-container/ingredients-list-container'; +import Modal from '../modal/modal'; +import IngredientDetails from '../ingredient-details/ingredient-details'; const BurgerIngredients = () => { - const [current, setCurrent] = useState(IngredientType.BUN); - const { - ingredients, - burgerData: { bunIngredient, mainIngredients }, - dispatch, - } = useContext(AppContext); - - const burgerIngredients = useMemo( - () => - bunIngredient - ? [bunIngredient, ...mainIngredients] - : [...mainIngredients], - [bunIngredient, mainIngredients] - ); - - const filteredIngredients = useMemo( - () => - ingredients.reduce((acc, ingredient) => { - const { type } = ingredient; - if (!acc[type]) { - acc[type] = [ingredient]; - return acc; - } + const dispatch = useDispatch(); + const currentIngredient = useSelector(getCurrentIngredient); + const [activeTab, setActiveTab] = useState(IngredientType.BUN); + const [isScrollMethod, setIsScrollMethod] = useState(false); - acc[type].push(ingredient); - return acc; - }, {}), - [ingredients] - ); - - const handleClickIngredientItem = useCallback( - (ingredient) => { - dispatch({ - type: SET_INGREDIENT, - payload: { ingredient, modalType: ModalType.INGREDIENT }, - }); - - if (ingredient.type === IngredientType.BUN) { - return dispatch({ type: ADD_BUN_INGREDIENT, payload: ingredient }); - } - - dispatch({ type: ADD_MAIN_INGREDIENT, payload: ingredient }); - }, - [dispatch] - ); + const handleCloseModal = useCallback(() => { + dispatch(setCurrentIngredient(null)); + }, [dispatch]); return ( -
-

Соберите бургер

-
- {useMemo( - () => - tabItems.map(({ name, type }) => ( - setCurrent(type)} - > - {name} - - )), - [current] - )} -
-
- {tabItems.map(({ name, type }) => ( - - ))} -
-
+ <> +
+

Соберите бургер

+ + +
+ {currentIngredient && ( + + + + )} + ); }; -export default memo(BurgerIngredients); +export default BurgerIngredients; diff --git a/src/components/burger-ingredients/burger-ingredients.module.css b/src/components/burger-ingredients/burger-ingredients.module.css index d5bb249..b647815 100644 --- a/src/components/burger-ingredients/burger-ingredients.module.css +++ b/src/components/burger-ingredients/burger-ingredients.module.css @@ -2,28 +2,3 @@ max-width: 600px; width: 100%; } - -.tabsBlock { - display: flex; -} - -.tabsBlock > div { - flex-grow: 1; -} - -.ingredientsContainer { - max-height: 756px; - overflow-y: auto; -} - -.ingredientsContainer::-webkit-scrollbar { - width: 8px; -} - -.ingredientsContainer::-webkit-scrollbar-track { - background: #2f2f37; -} - -.ingredientsContainer::-webkit-scrollbar-thumb { - background: #8585ad; -} diff --git a/src/components/hocs/with-modal.js b/src/components/hocs/with-modal.js deleted file mode 100644 index 153e955..0000000 --- a/src/components/hocs/with-modal.js +++ /dev/null @@ -1,50 +0,0 @@ -import { useContext } from 'react'; -import { ModalType } from '../../utils/constants'; -import { AppContext } from '../../services/app-context'; -import { CLOSE_MODAL } from '../../services/modules/app'; - -// Components -import Modal from '../modal/modal'; -import OrderDetails from '../order-details/order-details'; -import IngredientDetails from '../ingredient-details/ingredient-details'; - -const withModal = (WrappedComponent) => { - const WithModal = (props) => { - const { modalType, orderNumber, ingredient, dispatch } = useContext( - AppContext - ); - - const handleCloseModal = () => { - dispatch({ type: CLOSE_MODAL }); - }; - - const getComponent = () => { - switch (modalType) { - case ModalType.ORDER: - return ; - case ModalType.INGREDIENT: - return ; - default: - return; - } - }; - - return ( - <> - - {modalType && ( - - {getComponent()} - - )} - - ); - }; - - return WithModal; -}; - -export default withModal; diff --git a/src/components/ingredient-item/ingredient-item.jsx b/src/components/ingredient-item/ingredient-item.jsx index 2cd88cb..9e965f4 100644 --- a/src/components/ingredient-item/ingredient-item.jsx +++ b/src/components/ingredient-item/ingredient-item.jsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import pt from 'prop-types'; +import { useDrag } from 'react-dnd'; import { CurrencyIcon, Counter, @@ -12,11 +13,19 @@ const IngredientItem = ({ quantity, handleClickIngredientItem, }) => { + const [{ isDrag }, dragRef] = useDrag({ + type: 'ingredient', + item: ingredient, + collect: (monitor) => ({ + isDrag: monitor.isDragging(), + }), + }); const { name, image, price } = ingredient; return (
  • handleClickIngredientItem(ingredient)} > {name} diff --git a/src/components/ingredient-item/ingredient-item.module.css b/src/components/ingredient-item/ingredient-item.module.css index eab10d1..2e323cc 100644 --- a/src/components/ingredient-item/ingredient-item.module.css +++ b/src/components/ingredient-item/ingredient-item.module.css @@ -9,6 +9,11 @@ transition: all 0.2s linear; } +.draggableIngredientItem { + composes: ingredientItem; + opacity: 0.2; +} + .ingredientItem:hover { transform: scale(1.05); } diff --git a/src/components/ingredients-list-container/ingredients-list-container.jsx b/src/components/ingredients-list-container/ingredients-list-container.jsx new file mode 100644 index 0000000..0ba0fdb --- /dev/null +++ b/src/components/ingredients-list-container/ingredients-list-container.jsx @@ -0,0 +1,78 @@ +import { useRef, useMemo, useEffect, createRef } from 'react'; +import { useSelector } from 'react-redux'; +import pt from 'prop-types'; +import s from './ingredients-list-container.module.css'; +import { TAB_ITEMS } from '../../utils/constants'; +import { getFilteredIngredients } from '../../services/ducks/ingredients'; + +// Components +import IngredientsList from '../ingredients-list/ingredients-list'; + +const IngredientsListContainer = ({ + isScrollMethod, + activeTab, + setIsScrollMethod, + setActiveTab, +}) => { + const filteredIngredients = useSelector(getFilteredIngredients); + const containerRef = useRef(); + const ingredientsTitleRef = useMemo(() => { + return TAB_ITEMS.reduce((acc, { type }) => { + acc[type] = createRef(null); + return acc; + }, {}); + }, []); + + const handleScroll = () => { + const [nearestTitle] = TAB_ITEMS.reduce((acc, { type }) => { + acc.push({ + type, + distance: Math.abs( + containerRef.current.getBoundingClientRect().top - + ingredientsTitleRef[type].current.getBoundingClientRect().top + ), + }); + return acc; + }, []).sort((a, b) => a.distance - b.distance); + + if (activeTab !== nearestTitle.type) { + setActiveTab(nearestTitle.type); + setIsScrollMethod(true); + } + }; + + useEffect(() => { + if (!isScrollMethod) { + ingredientsTitleRef[activeTab].current.scrollIntoView(); + } + }, [activeTab, ingredientsTitleRef, isScrollMethod]); + + return ( +
    + {useMemo( + () => + TAB_ITEMS.map(({ name, type }) => ( + + )), + [filteredIngredients, ingredientsTitleRef] + )} +
    + ); +}; +IngredientsListContainer.propTypes = { + isScrollMethod: pt.bool.isRequired, + activeTab: pt.string.isRequired, + setIsScrollMethod: pt.func.isRequired, + setActiveTab: pt.func.isRequired, +}; + +export default IngredientsListContainer; diff --git a/src/components/ingredients-list-container/ingredients-list-container.module.css b/src/components/ingredients-list-container/ingredients-list-container.module.css new file mode 100644 index 0000000..28a46ae --- /dev/null +++ b/src/components/ingredients-list-container/ingredients-list-container.module.css @@ -0,0 +1,16 @@ +.ingredientsContainer { + max-height: 756px; + overflow-y: auto; +} + +.ingredientsContainer::-webkit-scrollbar { + width: 8px; +} + +.ingredientsContainer::-webkit-scrollbar-track { + background: #2f2f37; +} + +.ingredientsContainer::-webkit-scrollbar-thumb { + background: #8585ad; +} diff --git a/src/components/ingredients-list/ingredients-list.jsx b/src/components/ingredients-list/ingredients-list.jsx index 3eefb5d..8c7c645 100644 --- a/src/components/ingredients-list/ingredients-list.jsx +++ b/src/components/ingredients-list/ingredients-list.jsx @@ -1,42 +1,47 @@ -import { memo } from 'react'; +import { useCallback, forwardRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import pt from 'prop-types'; import { PropValidator } from '../../utils/prop-validator'; import s from './ingredients-list.module.css'; +import { getBurgerIgredientsIdsCount } from '../../services/ducks/burger-ingredients'; +import { setCurrentIngredient } from '../../services/ducks/current-ingredient'; // Components import IngredientItem from '../ingredient-item/ingredient-item'; -const IngredientsList = ({ - title, - ingredients, - burgerIngredients, - handleClickIngredientItem, -}) => { - const countIngredients = (id) => - burgerIngredients.filter((ingredient) => ingredient._id === id).length; +const IngredientsList = forwardRef(({ title, ingredients }, ref) => { + const burgerIngredientId = useSelector(getBurgerIgredientsIdsCount); + const dispatch = useDispatch(); + + const handleClickIngredientItem = useCallback( + (ingredient) => { + dispatch(setCurrentIngredient(ingredient)); + }, + [dispatch] + ); return ( <> -

    {title}

    +

    + {title} +

      {ingredients.map((ingredient) => ( ))}
    ); -}; +}); IngredientsList.propTypes = { title: pt.string.isRequired, ingredients: pt.arrayOf(PropValidator.INGREDIENT.isRequired).isRequired, - burgerIngredients: pt.arrayOf(PropValidator.INGREDIENT).isRequired, - handleClickIngredientItem: pt.func.isRequired, }; -export default memo(IngredientsList); +export default IngredientsList; diff --git a/src/components/ingredients-tabs/ingredients-tabs.jsx b/src/components/ingredients-tabs/ingredients-tabs.jsx new file mode 100644 index 0000000..1a033f8 --- /dev/null +++ b/src/components/ingredients-tabs/ingredients-tabs.jsx @@ -0,0 +1,34 @@ +import pt from 'prop-types'; +import s from './ingredients-tabs.module.css'; +import { TAB_ITEMS } from '../../utils/constants'; + +// Components +import { Tab } from '@ya.praktikum/react-developer-burger-ui-components'; + +const IngredientsTabs = ({ activeTab, setIsScrollMethod, setActiveTab }) => { + return ( +
    + {TAB_ITEMS.map(({ name, type }) => ( + { + setActiveTab(type); + setIsScrollMethod(false); + }} + > + {name} + + ))} +
    + ); +}; + +IngredientsTabs.propTypes = { + activeTab: pt.string.isRequired, + setIsScrollMethod: pt.func.isRequired, + setActiveTab: pt.func.isRequired, +}; + +export default IngredientsTabs; diff --git a/src/components/ingredients-tabs/ingredients-tabs.module.css b/src/components/ingredients-tabs/ingredients-tabs.module.css new file mode 100644 index 0000000..557b73b --- /dev/null +++ b/src/components/ingredients-tabs/ingredients-tabs.module.css @@ -0,0 +1,7 @@ +.tabsBlock { + display: flex; +} + +.tabsBlock > div { + flex-grow: 1; +} diff --git a/src/components/main-ingredient/main-ingredient.jsx b/src/components/main-ingredient/main-ingredient.jsx new file mode 100644 index 0000000..77d85d9 --- /dev/null +++ b/src/components/main-ingredient/main-ingredient.jsx @@ -0,0 +1,81 @@ +import { memo, useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { useDrag, useDrop } from 'react-dnd'; +import pt from 'prop-types'; +import { PropValidator } from '../../utils/prop-validator'; +import { + DragIcon, + ConstructorElement, +} from '@ya.praktikum/react-developer-burger-ui-components'; +import s from './main-ingredient.module.css'; +import { + removeIngredient, + sortMainIngredients, +} from '../../services/ducks/burger-ingredients'; + +const MainIngredient = ({ ingredient, ingredientIndex }) => { + const dispatch = useDispatch(); + const dragDropRef = useRef(null); + const { name, price, image_mobile } = ingredient; + + const [{ isDrag, draggingItem }, dragRef] = useDrag({ + type: 'main-ingredient', + item: { draggingItemIndex: ingredientIndex }, + collect: (monitor) => ({ + isDrag: monitor.isDragging(), + draggingItem: monitor.getItem(), + }), + }); + + const [{ isHover }, dropRef] = useDrop({ + accept: 'main-ingredient', + drop() { + dispatch( + sortMainIngredients({ + startIndex: draggingItem.draggingItemIndex, + moveToIndex: ingredientIndex, + }) + ); + }, + collect: (monitor) => ({ + isHover: monitor.isOver(), + }), + }); + + const isHoveredSameIngredient = + draggingItem?.draggingItemIndex === ingredientIndex; + + useEffect(() => { + dragRef(dragDropRef); + dropRef(dragDropRef); + }, [dragRef, dropRef]); + + const handleDeleteIngredient = (ingredientIndex) => { + dispatch(removeIngredient(ingredientIndex)); + }; + + return ( +
  • + + handleDeleteIngredient(ingredientIndex)} + /> +
  • + ); +}; + +MainIngredient.propTypes = { + ingredient: PropValidator.INGREDIENT.isRequired, + ingredientIndex: pt.number.isRequired, +}; + +export default memo(MainIngredient); diff --git a/src/components/main-ingredient/main-ingredient.module.css b/src/components/main-ingredient/main-ingredient.module.css new file mode 100644 index 0000000..ced0e8d --- /dev/null +++ b/src/components/main-ingredient/main-ingredient.module.css @@ -0,0 +1,34 @@ +.ingredientItem { + display: flex; + align-items: center; + margin-right: 25px; +} + +.ingredientItem > svg { + margin-right: 5px; +} + +.ingredientItem > div { + max-width: 536px; + width: 536px; +} + +.ingredientItemDragging { + opacity: 0.2; +} + +@keyframes color_change { + from { + background-color: #2f2f37; + } + to { + background-color: #4c4cff; + } +} + +.ingredientItemHovered > div { + animation-name: color_change; + animation-duration: 0.5s; + animation-iteration-count: infinite; + animation-direction: alternate; +} diff --git a/src/components/main-ingredients-list/main-ingredients-list.jsx b/src/components/main-ingredients-list/main-ingredients-list.jsx new file mode 100644 index 0000000..28dcdb3 --- /dev/null +++ b/src/components/main-ingredients-list/main-ingredients-list.jsx @@ -0,0 +1,31 @@ +import { memo } from 'react'; +import pt from 'prop-types'; +import { PropValidator } from '../../utils/prop-validator'; +import s from './main-ingredients-list.module.css'; + +// Components +import NoIngredient from '../no-ingredient/no-ingredient'; +import MainIngredient from '../main-ingredient/main-ingredient'; + +const MainIngredientsList = ({ ingredients }) => { + if (!ingredients.length) + return Выберите основные ингредиенты; + + return ( +
      + {ingredients.map((ingredient, index) => ( + + ))} +
    + ); +}; + +MainIngredientsList.propTypes = { + ingredients: pt.arrayOf(PropValidator.INGREDIENT.isRequired).isRequired, +}; + +export default memo(MainIngredientsList); diff --git a/src/components/main-ingredients-list/main-ingredients-list.module.css b/src/components/main-ingredients-list/main-ingredients-list.module.css new file mode 100644 index 0000000..4654909 --- /dev/null +++ b/src/components/main-ingredients-list/main-ingredients-list.module.css @@ -0,0 +1,20 @@ +.ingredientsList { + display: flex; + align-items: center; + flex-flow: column; + gap: 16px; + max-height: 465px; + overflow-y: auto; +} + +.ingredientsList::-webkit-scrollbar { + width: 8px; +} + +.ingredientsList::-webkit-scrollbar-track { + background: #2f2f37; +} + +.ingredientsList::-webkit-scrollbar-thumb { + background: #8585ad; +} diff --git a/src/components/no-ingredients/no-ingredients.jsx b/src/components/no-ingredients/no-ingredients.jsx new file mode 100644 index 0000000..a91eb1f --- /dev/null +++ b/src/components/no-ingredients/no-ingredients.jsx @@ -0,0 +1,17 @@ +import { memo } from 'react'; +import { BurgerIcon } from '@ya.praktikum/react-developer-burger-ui-components'; +import s from './no-ingredients.module.css'; + +const NoIngredients = () => { + return ( +
    + +

    Собери свой бургер

    +

    + Перетащите ингредиенты в конструктор +

    +
    + ); +}; + +export default memo(NoIngredients); diff --git a/src/components/no-ingredients/no-ingredients.module.css b/src/components/no-ingredients/no-ingredients.module.css new file mode 100644 index 0000000..fc3b63b --- /dev/null +++ b/src/components/no-ingredients/no-ingredients.module.css @@ -0,0 +1,16 @@ +.noIngredientsBlock { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + display: flex; + flex-flow: column; + align-items: center; +} + +.noIngredientsBlock svg { + width: 30%; + height: 30%; + margin-bottom: 25px; +} diff --git a/src/components/submit-section/submit-section.jsx b/src/components/submit-section/submit-section.jsx new file mode 100644 index 0000000..a440601 --- /dev/null +++ b/src/components/submit-section/submit-section.jsx @@ -0,0 +1,34 @@ +import { memo } from 'react'; +import pt from 'prop-types'; +import { + Button, + CurrencyIcon, +} from '@ya.praktikum/react-developer-burger-ui-components'; +import s from './submit-section.module.css'; + +const SubmitSection = ({ totalPrice, isDisabled, handleSendOrder }) => { + return ( +
    +

    + {totalPrice}  + +

    + +
    + ); +}; + +SubmitSection.propTypes = { + totalPrice: pt.number.isRequired, + isDisabled: pt.bool.isRequired, + handleSendOrder: pt.func.isRequired, +}; + +export default memo(SubmitSection); diff --git a/src/components/submit-section/submit-section.module.css b/src/components/submit-section/submit-section.module.css new file mode 100644 index 0000000..7eed5c9 --- /dev/null +++ b/src/components/submit-section/submit-section.module.css @@ -0,0 +1,27 @@ +.submitSection { + display: flex; + justify-content: flex-end; + align-items: center; + margin-right: 30px; +} + +.submitSection button { + cursor: pointer; +} + +.submitSection button:disabled { + box-shadow: none; + filter: none; + opacity: 0.2; + cursor: default; +} + +.totalPrice { + display: flex; + align-items: center; +} + +.totalPrice > svg { + width: 33px; + height: 33px; +} diff --git a/src/index.js b/src/index.js index 10ba03f..0ddbee3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { store } from './services/store'; + +// Components import App from './components/app/app'; ReactDOM.render( - + + + , document.getElementById('root') ); diff --git a/src/services/app-context.js b/src/services/app-context.js deleted file mode 100644 index e9220de..0000000 --- a/src/services/app-context.js +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react'; - -export const AppContext = createContext(); diff --git a/src/services/ducks/app.js b/src/services/ducks/app.js new file mode 100644 index 0000000..6510c66 --- /dev/null +++ b/src/services/ducks/app.js @@ -0,0 +1,22 @@ +import { createAction, createReducer } from '@reduxjs/toolkit'; +import { ActionPrefix } from '../../utils/constants'; + +// Actions +export const setError = createAction(`${ActionPrefix.APP}/setError`); + +// Reducer +const initialState = { + isError: false, +}; +const appReducer = createReducer(initialState, (builder) => { + builder + .addCase(setError, (state) => { + state.isError = true; + }) + .addDefaultCase((state) => state); +}); + +// Selectors +export const getAppState = ({ app }) => app; + +export default appReducer; diff --git a/src/services/ducks/burger-ingredients.js b/src/services/ducks/burger-ingredients.js new file mode 100644 index 0000000..e90c96d --- /dev/null +++ b/src/services/ducks/burger-ingredients.js @@ -0,0 +1,89 @@ +import { createReducer, createAction, createSelector } from '@reduxjs/toolkit'; +import { IngredientType } from '../../utils/constants'; +import { ActionPrefix } from '../../utils/constants'; + +// Actions +export const addIngredient = createAction( + `${ActionPrefix.BURGER_INGREDIENTS}/addIngredient` +); +export const removeIngredient = createAction( + `${ActionPrefix.BURGER_INGREDIENTS}/removeIngredient` +); +export const sortMainIngredients = createAction( + `${ActionPrefix.BURGER_INGREDIENTS}/sortMainIngredients` +); +export const resetBurgerIngredients = createAction( + `${ActionPrefix.BURGER_INGREDIENTS}/resetBurgerIngredients` +); + +// Reducer +const initialState = { + burgerData: { + bunIngredient: null, + mainIngredients: [], + }, +}; + +const reducer = createReducer(initialState, (builder) => { + builder + .addCase(addIngredient, (state, { payload: ingredient }) => { + if (ingredient.type === IngredientType.BUN) { + state.burgerData.bunIngredient = ingredient; + return; + } + state.burgerData.mainIngredients.push(ingredient); + }) + .addCase(sortMainIngredients, (state, { payload }) => { + const { startIndex, moveToIndex } = payload; + const ingredient = state.burgerData.mainIngredients[startIndex]; + const mainIngredients = [ + ...state.burgerData.mainIngredients.slice(0, startIndex), + ...state.burgerData.mainIngredients.slice(startIndex + 1), + ]; + + mainIngredients.splice(moveToIndex, 0, ingredient); + state.burgerData.mainIngredients = mainIngredients; + }) + .addCase(removeIngredient, (state, { payload: ingredientIndex }) => { + state.burgerData.mainIngredients.splice(ingredientIndex, 1); + }) + .addCase(resetBurgerIngredients, () => initialState) + .addDefaultCase((state) => state); +}); + +// Selectors +export const getBurgerIngredients = ({ burgerIngredients }) => + burgerIngredients.burgerData; + +export const getTotalPrice = createSelector( + getBurgerIngredients, + (burgerIngredients) => { + const { bunIngredient, mainIngredients } = burgerIngredients; + const bunPrice = bunIngredient?.price * 2 || 0; + + const totalPrice = + mainIngredients.reduce( + (totalPrice, { price }) => (totalPrice += price), + 0 + ) + bunPrice; + + return totalPrice; + } +); + +export const getBurgerIgredientsIdsCount = createSelector( + getBurgerIngredients, + (burgerIngredients) => { + const { bunIngredient, mainIngredients } = burgerIngredients; + const ingredients = bunIngredient + ? [bunIngredient, ...mainIngredients] + : mainIngredients; + + return ingredients.reduce((acc, { _id }) => { + !acc[_id] ? (acc[_id] = 1) : (acc[_id] += 1); + return acc; + }, {}); + } +); + +export default reducer; diff --git a/src/services/ducks/current-ingredient.js b/src/services/ducks/current-ingredient.js new file mode 100644 index 0000000..11c2341 --- /dev/null +++ b/src/services/ducks/current-ingredient.js @@ -0,0 +1,26 @@ +import { createAction, createReducer } from '@reduxjs/toolkit'; +import { ActionPrefix } from '../../utils/constants'; + +// Actions +export const setCurrentIngredient = createAction( + `${ActionPrefix.CURRENT_INGREDIENT}/setCurrentIngredient` +); + +// Reducer +const initialState = { + ingredient: null, +}; + +const reducer = createReducer(initialState, (builder) => { + builder + .addCase(setCurrentIngredient, (state, { payload }) => { + state.ingredient = payload; + }) + .addDefaultCase((state) => state); +}); + +// Selectors +export const getCurrentIngredient = ({ currentIngredient }) => + currentIngredient.ingredient; + +export default reducer; diff --git a/src/services/ducks/index.js b/src/services/ducks/index.js new file mode 100644 index 0000000..469b478 --- /dev/null +++ b/src/services/ducks/index.js @@ -0,0 +1,15 @@ +import appReducer from './app'; +import ingredientsReducer from './ingredients'; +import burderIngredientsReducer from './burger-ingredients'; +import ingredientReducer from './current-ingredient'; +import orderReducer from './order'; + +const rootReducer = { + app: appReducer, + ingredients: ingredientsReducer, + burgerIngredients: burderIngredientsReducer, + currentIngredient: ingredientReducer, + order: orderReducer, +}; + +export default rootReducer; diff --git a/src/services/ducks/ingredients.js b/src/services/ducks/ingredients.js new file mode 100644 index 0000000..45a05b0 --- /dev/null +++ b/src/services/ducks/ingredients.js @@ -0,0 +1,57 @@ +import { + createReducer, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit'; +import { ApiRoute } from '../../utils/constants'; +import { setError } from './app'; +import { ActionPrefix } from '../../utils/constants'; + +// Actions +export const fetchAllIngredients = createAsyncThunk( + `${ActionPrefix.INGREDIENTS}/fetchAllIngredients`, + async (_, { dispatch, rejectWithValue, extra: request }) => { + try { + const { data: ingredients } = await request(ApiRoute.INGREDIENTS); + return ingredients; + } catch { + dispatch(setError()); + return rejectWithValue(); + } + } +); + +// Reducer +const initialState = { + ingredientsList: [], + isLoading: true, +}; + +const reducer = createReducer(initialState, (builder) => { + builder + .addCase(fetchAllIngredients.fulfilled, (state, { payload }) => { + state.ingredientsList = payload; + state.isLoading = false; + }) + .addCase(fetchAllIngredients.rejected, (state) => { + state.ingredientsList = []; + state.isLoading = false; + }) + .addDefaultCase((state) => state); +}); + +// Selectors +export const getIngredientsState = ({ ingredients }) => ingredients; +const getAllIngredients = ({ ingredients }) => ingredients.ingredientsList; + +export const getFilteredIngredients = createSelector( + getAllIngredients, + (ingredients) => + ingredients.reduce((acc, ingredient) => { + const { type } = ingredient; + !acc[type] ? (acc[type] = [ingredient]) : acc[type].push(ingredient); + return acc; + }, {}) +); + +export default reducer; diff --git a/src/services/ducks/order.js b/src/services/ducks/order.js new file mode 100644 index 0000000..dd89886 --- /dev/null +++ b/src/services/ducks/order.js @@ -0,0 +1,57 @@ +import { + createReducer, + createAsyncThunk, + createAction, +} from '@reduxjs/toolkit'; +import { ApiRoute } from '../../utils/constants'; +import { setError } from './app'; +import { ActionPrefix } from '../../utils/constants'; + +// Actions +export const resetOrder = createAction(`${ActionPrefix.ORDER}/resetOrder`); + +export const sendOrder = createAsyncThunk( + `${ActionPrefix.ORDER}/sendOrder`, + async (ingredientsIds, { dispatch, rejectWithValue, extra: request }) => { + try { + const { + order: { number }, + } = await request(ApiRoute.ORDERS, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ingredients: ingredientsIds }), + }); + return number; + } catch { + dispatch(setError()); + return rejectWithValue(); + } + } +); + +// Reducer +const initialState = { + orderNumber: null, + isLoading: false, +}; + +const reducer = createReducer(initialState, (builder) => { + builder + .addCase(sendOrder.pending, (state) => { + state.isLoading = true; + }) + .addCase(sendOrder.fulfilled, (state, { payload }) => { + state.orderNumber = payload; + state.isLoading = false; + }) + .addCase(sendOrder.rejected, () => initialState) + .addCase(resetOrder, (state) => { + state.orderNumber = null; + }) + .addDefaultCase((state) => state); +}); + +// Selectors +export const getOrderState = ({ order }) => order; + +export default reducer; diff --git a/src/services/modules/app.js b/src/services/modules/app.js deleted file mode 100644 index 557fa8a..0000000 --- a/src/services/modules/app.js +++ /dev/null @@ -1,80 +0,0 @@ -// Action types -export const FETCH_INGREDIENTS = 'FETCH_INGREDIENTS'; -export const FETCH_INGREDIENTS_ERROR = 'FETCH_INGREDIENTS_ERROR'; -export const SEND_ORDER_ERROR = 'SEND_ORDER_ERROR'; -export const ADD_BUN_INGREDIENT = 'ADD_BUN_INGREDIENT'; -export const ADD_MAIN_INGREDIENT = 'ADD_MAIN_INGREDIENT'; -export const SET_INGREDIENT = 'SET_INGREDIENT'; -export const SET_ORDER_NUMBER = 'SET_ORDER_NUMBER'; -export const SET_TOTAL_PRICE = 'SET_TOTAL_PRICE'; -export const DELETE_INGREDIENT = 'DELETE_INGREDIENT'; -export const CLOSE_MODAL = 'CLOSE_MODAL'; - -// Reducer -const appReducer = (state, { type, payload = null }) => { - switch (type) { - case 'FETCH_INGREDIENTS': - return { - ...state, - ingredients: payload, - loading: false, - }; - case 'FETCH_INGREDIENTS_ERROR': - case 'SEND_ORDER_ERROR': - return { - ...state, - loading: false, - error: true, - }; - case 'ADD_BUN_INGREDIENT': - return { - ...state, - burgerData: { - ...state.burgerData, - bunIngredient: payload, - }, - }; - case 'ADD_MAIN_INGREDIENT': - return { - ...state, - burgerData: { - ...state.burgerData, - mainIngredients: [...state.burgerData.mainIngredients, payload], - }, - }; - case 'SET_INGREDIENT': - return { - ...state, - ingredient: payload.ingredient, - modalType: payload.modalType, - }; - case 'SET_ORDER_NUMBER': - return { - ...state, - orderNumber: payload.orderNumber, - modalType: payload.modalType, - }; - case 'SET_TOTAL_PRICE': - return { - ...state, - totalPrice: payload, - }; - case 'DELETE_INGREDIENT': - return { - ...state, - burgerData: { - ...state.burgerData, - mainIngredients: payload, - }, - }; - case 'CLOSE_MODAL': - return { - ...state, - modalType: null, - }; - default: - return state; - } -}; - -export default appReducer; diff --git a/src/services/store.js b/src/services/store.js new file mode 100644 index 0000000..b63a231 --- /dev/null +++ b/src/services/store.js @@ -0,0 +1,14 @@ +import { configureStore } from '@reduxjs/toolkit'; +import rootReducer from './ducks'; +import request from '../utils/api'; + +export const store = configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + thunk: { + extraArgument: request, + }, + }), + devTools: process.env.NODE_ENV !== 'production', +}); diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..f47d0ba --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,18 @@ +export const ENDPOINT = 'https://norma.nomoreparties.space/api'; + +const request = async (url, options = null) => { + try { + const response = await fetch(`${ENDPOINT}/${url}`, options); + + if (response.ok) { + const data = await response.json(); + return data; + } + + throw new Error(); + } catch (err) { + throw new Error(err); + } +}; + +export default request; diff --git a/src/utils/constants.js b/src/utils/constants.js index fa5a793..118f47a 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -1,10 +1,38 @@ -export const ModalType = { - INGREDIENT: 'ingredient', - ORDER: 'order', -}; - -export const ENDPOINT = 'https://norma.nomoreparties.space/api'; export const ApiRoute = { INGREDIENTS: 'ingredients', ORDERS: 'orders', }; + +export const BunPosition = { + TOP: 'top', + BOTTOM: 'bottom', +}; + +export const IngredientType = { + BUN: 'bun', + SAUCE: 'sauce', + MAIN: 'main', +}; + +export const TAB_ITEMS = [ + { + name: 'Булки', + type: IngredientType.BUN, + }, + { + name: 'Соусы', + type: IngredientType.SAUCE, + }, + { + name: 'Начинки', + type: IngredientType.MAIN, + }, +]; + +export const ActionPrefix = { + APP: 'app', + ORDER: 'app/order', + INGREDIENTS: 'app/ingredients', + CURRENT_INGREDIENT: 'app/currentIngredient', + BURGER_INGREDIENTS: 'app/burgerIngredients', +};