├── .gitignore
├── .idea
├── .gitignore
├── modules.xml
├── react-pizza.iml
├── vcs.xml
└── workspace.xml
├── README.md
├── package-lock.json
├── package.json
├── public
├── arrow-top.svg
├── cart.svg
├── empty-cart.png
├── favicon.ico
├── grey-arrow-left.svg
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
├── pizza-logo.svg
├── plus.svg
├── robots.txt
└── trash.svg
├── src
├── @types
│ └── assets.d.ts
├── App.css
├── App.test.js
├── App.tsx
├── assets
│ └── img
│ │ ├── arrow-top.svg
│ │ ├── cart.svg
│ │ ├── empty-cart.png
│ │ ├── grey-arrow-left.svg
│ │ ├── pizza-logo.svg
│ │ ├── plus.svg
│ │ └── trash.svg
├── components
│ ├── CartEmpty.tsx
│ ├── CartItem.tsx
│ ├── Categories.tsx
│ ├── Header.tsx
│ ├── NotFoundBlock
│ │ ├── NotFoundBlock.module.scss
│ │ └── index.tsx
│ ├── Pagination
│ │ ├── Pagination.module.scss
│ │ └── index.tsx
│ ├── PizzaBlock
│ │ ├── Skeleton.tsx
│ │ └── index.tsx
│ ├── Search
│ │ ├── Search.module.scss
│ │ └── index.tsx
│ ├── Sort.tsx
│ └── index.ts
├── index.tsx
├── layouts
│ └── MainLayout.tsx
├── logo.svg
├── pages
│ ├── Cart.tsx
│ ├── FullPizza.tsx
│ ├── Home.tsx
│ └── NotFound.tsx
├── redux
│ ├── cart
│ │ ├── selectors.ts
│ │ ├── slice.ts
│ │ └── types.ts
│ ├── filter
│ │ ├── selectors.ts
│ │ ├── slice.ts
│ │ └── types.ts
│ ├── pizza
│ │ ├── asyncActions.ts
│ │ ├── selectors.ts
│ │ ├── slice.ts
│ │ └── types.ts
│ └── store.ts
├── reportWebVitals.js
├── scss
│ ├── _variables.scss
│ ├── app.scss
│ ├── components
│ │ ├── _all.scss
│ │ ├── _button.scss
│ │ ├── _categories.scss
│ │ ├── _header.scss
│ │ ├── _pizza-block.scss
│ │ └── _sort.scss
│ └── libs
│ │ └── _normalize.scss
├── setupTests.js
└── utils
│ ├── calcTotalPrice.ts
│ ├── getCartFromLS.ts
│ └── math.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacorm/react-pizza/4897bf2844b55476cb682d0cdf4feccf3a2cad0b/.idea/.gitignore
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/react-pizza.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | 1653410114388
108 |
109 |
110 | 1653410114388
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | 1653418426341
148 |
149 |
150 |
151 | 1653418426341
152 |
153 |
154 | 1653424054299
155 |
156 |
157 |
158 | 1653424054299
159 |
160 |
161 | 1653427638122
162 |
163 |
164 |
165 | 1653427638122
166 |
167 |
168 | 1653428100685
169 |
170 |
171 |
172 | 1653428100685
173 |
174 |
175 | 1653462359983
176 |
177 |
178 |
179 | 1653462359983
180 |
181 |
182 | 1653462555362
183 |
184 |
185 |
186 | 1653462555362
187 |
188 |
189 | 1653462706767
190 |
191 |
192 |
193 | 1653462706767
194 |
195 |
196 | 1653472791325
197 |
198 |
199 |
200 | 1653472791325
201 |
202 |
203 | 1653477870897
204 |
205 |
206 |
207 | 1653477870897
208 |
209 |
210 | 1654380177135
211 |
212 |
213 |
214 | 1654380177135
215 |
216 |
217 | 1654457327431
218 |
219 |
220 |
221 | 1654457327431
222 |
223 |
224 | 1654457567671
225 |
226 |
227 |
228 | 1654457567671
229 |
230 |
231 | 1654467649444
232 |
233 |
234 |
235 | 1654467649444
236 |
237 |
238 | 1656762945715
239 |
240 |
241 |
242 | 1656762945715
243 |
244 |
245 | 1656763716630
246 |
247 |
248 |
249 | 1656763716630
250 |
251 |
252 | 1656764148975
253 |
254 |
255 |
256 | 1656764148975
257 |
258 |
259 | 1656848224026
260 |
261 |
262 |
263 | 1656848224026
264 |
265 |
266 |
267 |
268 |
269 |
270 |
275 |
276 |
277 |
278 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🍕 React Pizza 🍕
2 |
3 | Веб приложение пиццерии с функцией выбора пиццы и заказа по аналогии DodoPizza
4 |
5 |
6 | # 🛠 Технологии:
7 |
8 | - **TypeScript**
9 | - **ReactJS 18**
10 | - **React Hooks** (хуки)
11 | - **Redux Toolkit** (Хранение данных)
12 | - **Axios + fetch** (Отправка запроса на бэкенд)
13 | - **Prettier** (форматирование кода)
14 | - **Webpack**
15 | - **React Router v6**
16 | - React Content Loader (скелетон)
17 | - React Pagination (пагинация)
18 | - Lodash.Debounce
19 | - Code Splitting, React Loadable, useWhyDidYouUpdate
20 | - CSS-Modules / SCSS (стилизация)
21 |
22 |
23 | # Функционал:
24 |
25 | - **Выбор теста пиццы**
26 | - **Выбор размера пиццы**
27 | - **Выбор категории пиццы**
28 | - **Сортировка пиццы**
29 | - **Поиск пиццы**
30 | - **Добавление в корзину**
31 | - **Адаптивность до 320px**
32 |
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-pizza-v2",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reduxjs/toolkit": "^1.8.1",
7 | "@testing-library/jest-dom": "^5.16.4",
8 | "@testing-library/react": "^13.1.1",
9 | "@testing-library/user-event": "^13.5.0",
10 | "@types/jest": "^27.5.0",
11 | "@types/lodash.debounce": "^4.0.7",
12 | "@types/node": "^17.0.31",
13 | "@types/react": "^18.0.8",
14 | "@types/react-dom": "^18.0.3",
15 | "@types/react-loadable": "^5.5.6",
16 | "ahooks": "^3.3.10",
17 | "axios": "^0.27.2",
18 | "clsx": "^1.1.1",
19 | "lodash.debounce": "^4.0.8",
20 | "qs": "^6.10.3",
21 | "react": "^18.1.0",
22 | "react-content-loader": "^6.2.0",
23 | "react-dom": "^18.1.0",
24 | "react-loadable": "^5.5.0",
25 | "react-paginate": "^8.1.3",
26 | "react-redux": "^8.0.1",
27 | "react-router-dom": "^6.3.0",
28 | "react-scripts": "5.0.1",
29 | "sass": "^1.51.0",
30 | "typescript": "^4.6.4",
31 | "web-vitals": "^2.1.4"
32 | },
33 | "scripts": {
34 | "start": "react-scripts start",
35 | "build": "react-scripts build",
36 | "test": "react-scripts test",
37 | "eject": "react-scripts eject"
38 | },
39 | "eslintConfig": {
40 | "extends": [
41 | "react-app",
42 | "react-app/jest"
43 | ]
44 | },
45 | "browserslist": {
46 | "production": [
47 | ">0.2%",
48 | "not dead",
49 | "not op_mini all"
50 | ],
51 | "development": [
52 | "last 1 chrome version",
53 | "last 1 firefox version",
54 | "last 1 safari version"
55 | ]
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/public/arrow-top.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/cart.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/empty-cart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacorm/react-pizza/4897bf2844b55476cb682d0cdf4feccf3a2cad0b/public/empty-cart.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacorm/react-pizza/4897bf2844b55476cb682d0cdf4feccf3a2cad0b/public/favicon.ico
--------------------------------------------------------------------------------
/public/grey-arrow-left.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
30 | React App
31 |
32 |
33 |
34 |
35 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacorm/react-pizza/4897bf2844b55476cb682d0cdf4feccf3a2cad0b/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacorm/react-pizza/4897bf2844b55476cb682d0cdf4feccf3a2cad0b/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/pizza-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/plus.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/trash.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/@types/assets.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: any;
3 | export default content;
4 | }
5 |
6 | declare module '*.png' {
7 | const content: any;
8 | export default content;
9 | }
10 |
11 | declare module '*.scss' {
12 | const content: any;
13 | export default content;
14 | }
15 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Loadable from 'react-loadable';
2 | import React, { Suspense } from 'react';
3 | import { Routes, Route } from 'react-router-dom';
4 |
5 | import Home from './pages/Home';
6 |
7 | import './scss/app.scss';
8 | import MainLayout from './layouts/MainLayout';
9 |
10 | const Cart = Loadable({
11 | loader: () => import(/* webpackChunkName: "Cart" */ './pages/Cart'),
12 | loading: () => Идёт загрузка корзины...
,
13 | });
14 |
15 | const FullPizza = React.lazy(() => import(/* webpackChunkName: "FullPizza" */ './pages/FullPizza'));
16 | const NotFound = React.lazy(() => import(/* webpackChunkName: "NotFound" */ './pages/NotFound'));
17 |
18 | function App() {
19 | return (
20 |
21 | }>
22 | } />
23 | Идёт загрузка корзины...}>
27 |
28 |
29 | }
30 | />
31 | Идёт загрузка...}>
35 |
36 |
37 | }
38 | />
39 | Идёт загрузка...}>
43 |
44 |
45 | }
46 | />
47 |
48 |
49 | );
50 | }
51 |
52 | export default App;
--------------------------------------------------------------------------------
/src/assets/img/arrow-top.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/img/cart.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/assets/img/empty-cart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacorm/react-pizza/4897bf2844b55476cb682d0cdf4feccf3a2cad0b/src/assets/img/empty-cart.png
--------------------------------------------------------------------------------
/src/assets/img/grey-arrow-left.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/img/pizza-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/img/plus.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/assets/img/trash.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/components/CartEmpty.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import cartEmptyImg from '../assets/img/empty-cart.png';
5 |
6 | export const CartEmpty: React.FC = () => (
7 |
8 |
9 | Корзина пустая 😕
10 |
11 |
12 | Вероятней всего, вы не заказывали ещё пиццу.
13 |
14 | Для того, чтобы заказать пиццу, перейди на главную страницу.
15 |
16 |

17 |
18 |
Вернуться назад
19 |
20 |
21 | );
--------------------------------------------------------------------------------
/src/components/CartItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { addItem, minusItem, removeItem } from '../redux/cart/slice';
4 | import { CartItem as CartItemType } from '../redux/cart/types';
5 |
6 | type CartItemProps = {
7 | id: string;
8 | title: string;
9 | type: string;
10 | size: number;
11 | price: number;
12 | count: number;
13 | imageUrl: string;
14 | };
15 |
16 | export const CartItem: React.FC = ({
17 | id,
18 | title,
19 | type,
20 | size,
21 | price,
22 | count,
23 | imageUrl,
24 | }) => {
25 | const dispatch = useDispatch();
26 |
27 | const onClickPlus = () => {
28 | dispatch(
29 | addItem({
30 | id,
31 | } as CartItemType),
32 | );
33 | };
34 |
35 | const onClickMinus = () => {
36 | dispatch(minusItem(id));
37 | };
38 |
39 | const onClickRemove = () => {
40 | if (window.confirm('Ты действительно хочешь удалить товар?')) {
41 | dispatch(removeItem(id));
42 | }
43 | };
44 |
45 | return (
46 |
47 |
48 |

49 |
50 |
51 |
{title}
52 |
53 | {type}, {size} см.
54 |
55 |
56 |
57 |
75 |
{count}
76 |
93 |
94 |
95 | {price * count} ₽
96 |
97 |
98 |
99 |
112 |
113 |
114 |
115 | );
116 | };
--------------------------------------------------------------------------------
/src/components/Categories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | type CategoriesProps = {
4 | value: number;
5 | onChangeCategory: (idx: number) => void;
6 | };
7 |
8 | const categories = ['Все', 'Мясные', 'Вегетарианская', 'Гриль', 'Острые', 'Закрытые'];
9 |
10 | export const Categories: React.FC = React.memo(({ value, onChangeCategory }) => {
11 | return (
12 |
13 |
14 | {categories.map((categoryName, i) => (
15 | - onChangeCategory(i)} className={value === i ? 'active' : ''}>
16 | {categoryName}
17 |
18 | ))}
19 |
20 |
21 | );
22 | });
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import { useSelector } from 'react-redux';
4 |
5 | import logoSvg from '../assets/img/pizza-logo.svg';
6 | import { Search } from './';
7 | import { selectCart } from '../redux/cart/selectors';
8 |
9 | export const Header: React.FC = () => {
10 | const { items, totalPrice } = useSelector(selectCart);
11 | const location = useLocation();
12 | const isMounted = React.useRef(false);
13 |
14 | const totalCount = items.reduce((sum: number, item: any) => sum + item.count, 0);
15 |
16 | React.useEffect(() => {
17 | if (isMounted.current) {
18 | const json = JSON.stringify(items);
19 | localStorage.setItem('cart', json);
20 | }
21 | isMounted.current = true;
22 | }, [items]);
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |

30 |
31 |
React Pizza V2
32 |
самая вкусная пицца во вселенной
33 |
34 |
35 |
36 | {location.pathname !== '/cart' &&
}
37 |
38 | {location.pathname !== '/cart' && (
39 |
40 |
{totalPrice} ₽
41 |
42 |
70 |
{totalCount}
71 |
72 | )}
73 |
74 |
75 |
76 | );
77 | };
--------------------------------------------------------------------------------
/src/components/NotFoundBlock/NotFoundBlock.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 100px;
3 | max-width: 750px;
4 | margin: 0 auto;
5 | text-align: center;
6 |
7 | span {
8 | font-size: 64px;
9 | }
10 | }
11 |
12 | .description {
13 | font-size: 22px;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/NotFoundBlock/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styles from './NotFoundBlock.module.scss';
4 |
5 | export const NotFoundBlock: React.FC = () => {
6 | return (
7 |
8 |
9 | 😕
10 |
11 | Ничего не найдено
12 |
13 |
14 | К сожалени данная страница отсутствует в нашем интернет-магазине
15 |
16 |
17 | );
18 | };
--------------------------------------------------------------------------------
/src/components/Pagination/Pagination.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | li {
3 | display: inline-block;
4 |
5 | a {
6 | text-align: center;
7 | width: 45px;
8 | line-height: 42px;
9 | height: 45px;
10 | border: 1px solid #fe5f1e;
11 | border-radius: 30px;
12 | margin-right: 10px;
13 | cursor: pointer;
14 | display: inline-block;
15 | color: #fe5f1e;
16 |
17 | &:hover {
18 | background-color: #fe5f1e;
19 | color: #fff;
20 | }
21 | }
22 | }
23 |
24 | :global {
25 | .selected {
26 | a {
27 | background-color: #fe5f1e;
28 | color: #fff;
29 | }
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/Pagination/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactPaginate from 'react-paginate';
3 |
4 | import styles from './Pagination.module.scss';
5 |
6 | type PaginationProps = {
7 | currentPage: number;
8 | onChangePage: (page: number) => void;
9 | };
10 |
11 | export const Pagination: React.FC = ({ currentPage, onChangePage }) => (
12 | onChangePage(event.selected + 1)}
18 | pageRangeDisplayed={4}
19 | pageCount={3}
20 | forcePage={currentPage - 1}
21 | />
22 | );
--------------------------------------------------------------------------------
/src/components/PizzaBlock/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ContentLoader from 'react-content-loader';
3 |
4 | export const Skeleton = () => (
5 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/src/components/PizzaBlock/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { selectCartItemById } from '../../redux/cart/selectors';
5 | import { CartItem } from '../../redux/cart/types';
6 | import { addItem } from '../../redux/cart/slice';
7 |
8 | const typeNames = ['тонкое', 'традиционное'];
9 |
10 | type PizzaBlockProps = {
11 | id: string;
12 | title: string;
13 | price: number;
14 | imageUrl: string;
15 | sizes: number[];
16 | types: number[];
17 | rating: number;
18 | };
19 |
20 | export const PizzaBlock: React.FC = ({
21 | id,
22 | title,
23 | price,
24 | imageUrl,
25 | sizes,
26 | types,
27 | }) => {
28 | const dispatch = useDispatch();
29 | const cartItem = useSelector(selectCartItemById(id));
30 | const [activeType, setActiveType] = React.useState(0);
31 | const [activeSize, setActiveSize] = React.useState(0);
32 |
33 | const addedCount = cartItem ? cartItem.count : 0;
34 |
35 | const onClickAdd = () => {
36 | const item: CartItem = {
37 | id,
38 | title,
39 | price,
40 | imageUrl,
41 | type: typeNames[activeType],
42 | size: sizes[activeSize],
43 | count: 0,
44 | };
45 | dispatch(addItem(item));
46 | };
47 |
48 | return (
49 |
50 |
51 |
52 |

53 |
{title}
54 |
55 |
56 |
57 | {types.map((typeId) => (
58 | - setActiveType(typeId)}
61 | className={activeType === typeId ? 'active' : ''}>
62 | {typeNames[typeId]}
63 |
64 | ))}
65 |
66 |
67 | {sizes.map((size, i) => (
68 | - setActiveSize(i)}
71 | className={activeSize === i ? 'active' : ''}>
72 | {size} см.
73 |
74 | ))}
75 |
76 |
77 |
78 |
от {price} ₽
79 |
94 |
95 |
96 |
97 | );
98 | };
--------------------------------------------------------------------------------
/src/components/Search/Search.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | position: relative;
3 | left: -120px;
4 | }
5 |
6 | .input {
7 | border: 1px solid rgba(0, 0, 0, 0.1);
8 | padding: 12px 20px;
9 | padding-left: 42px;
10 | width: 400px;
11 | border-radius: 10px;
12 | font-size: 16px;
13 |
14 | &:focus {
15 | border: 1px solid rgba(0, 0, 0, 0.2);
16 | }
17 | }
18 |
19 | .icon {
20 | width: 22px;
21 | height: 22px;
22 | opacity: 0.3;
23 | position: absolute;
24 | left: 14px;
25 | top: 12px;
26 | }
27 |
28 | .clearIcon {
29 | width: 18px;
30 | height: 18px;
31 | opacity: 0.3;
32 | position: absolute;
33 | right: 15px;
34 | top: 15px;
35 | cursor: pointer;
36 |
37 | &:hover {
38 | opacity: 0.8;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Search/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import debounce from 'lodash.debounce';
4 |
5 | import styles from './Search.module.scss';
6 | import { setSearchValue } from '../../redux/filter/slice';
7 |
8 | export const Search: React.FC = () => {
9 | const dispatch = useDispatch();
10 | const [value, setValue] = React.useState('');
11 | const inputRef = React.useRef(null);
12 |
13 | const onClickClear = () => {
14 | dispatch(setSearchValue(''));
15 | setValue('');
16 | inputRef.current?.focus();
17 | };
18 |
19 | const updateSearchValue = React.useCallback(
20 | debounce((str: string) => {
21 | dispatch(setSearchValue(str));
22 | }, 150),
23 | [],
24 | );
25 |
26 | const onChangeInput = (event: React.ChangeEvent) => {
27 | setValue(event.target.value);
28 | updateSearchValue(event.target.value);
29 | };
30 |
31 | return (
32 |
33 |
66 |
73 | {value && (
74 |
81 | )}
82 |
83 | );
84 | };
--------------------------------------------------------------------------------
/src/components/Sort.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { setSort } from '../redux/filter/slice';
4 | import { Sort as SortType, SortPropertyEnum } from '../redux/filter/types';
5 |
6 | type SortItem = {
7 | name: string;
8 | sortProperty: SortPropertyEnum;
9 | };
10 |
11 | type PopupClick = MouseEvent & {
12 | path: Node[];
13 | };
14 |
15 | type SortPopupProps = {
16 | value: SortType;
17 | };
18 |
19 | export const sortList: SortItem[] = [
20 | { name: 'популярности', sortProperty: SortPropertyEnum.RATING_DESC },
21 | { name: 'цене', sortProperty: SortPropertyEnum.PRICE_DESC },
22 | { name: 'алфавиту', sortProperty: SortPropertyEnum.TITLE_DESC },
23 | ];
24 |
25 | export const Sort: React.FC = React.memo(({ value }) => {
26 | const dispatch = useDispatch();
27 | const sortRef = React.useRef(null);
28 |
29 | const [open, setOpen] = React.useState(false);
30 |
31 | const onClickListItem = (obj: SortItem) => {
32 | dispatch(setSort(obj));
33 | setOpen(false);
34 | };
35 |
36 | React.useEffect(() => {
37 | const handleClickOutside = (event: MouseEvent) => {
38 | const _event = event as PopupClick;
39 |
40 | if (sortRef.current && !_event.path.includes(sortRef.current)) {
41 | setOpen(false);
42 | }
43 | };
44 |
45 | document.body.addEventListener('click', handleClickOutside);
46 |
47 | return () => document.body.removeEventListener('click', handleClickOutside);
48 | }, []);
49 |
50 | return (
51 |
52 |
53 |
64 |
Сортировка по:
65 |
setOpen(!open)}>{value.name}
66 |
67 | {open && (
68 |
69 |
70 | {sortList.map((obj, i) => (
71 | - onClickListItem(obj)}
74 | className={value.sortProperty === obj.sortProperty ? 'active' : ''}>
75 | {obj.name}
76 |
77 | ))}
78 |
79 |
80 | )}
81 |
82 | );
83 | });
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './PizzaBlock/Skeleton';
2 | export * from './PizzaBlock';
3 | export * from './Header';
4 | export * from './Categories';
5 | export * from './CartItem';
6 | export * from './CartEmpty';
7 | export * from './Search';
8 | export * from './Pagination';
9 | export * from './NotFoundBlock';
10 | export * from './Sort';
11 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client';
2 | import { BrowserRouter } from 'react-router-dom';
3 | import { Provider } from 'react-redux';
4 |
5 | import App from './App';
6 |
7 | import { store } from './redux/store';
8 |
9 | const rootElem = document.getElementById('root');
10 |
11 | if (rootElem) {
12 | const root = ReactDOM.createRoot(rootElem);
13 |
14 | root.render(
15 |
16 |
17 |
18 |
19 | ,
20 | );
21 | }
--------------------------------------------------------------------------------
/src/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Outlet } from 'react-router-dom';
3 |
4 | import { Header } from '../components';
5 |
6 | const MainLayout: React.FC = () => {
7 | return (
8 |
14 | );
15 | };
16 |
17 | export default MainLayout;
18 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/Cart.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { useDispatch, useSelector } from 'react-redux';
4 |
5 | import { CartItem, CartEmpty } from '../components';
6 |
7 | import { selectCart } from '../redux/cart/selectors';
8 | import { clearItems } from '../redux/cart/slice';
9 |
10 | const Cart: React.FC = () => {
11 | const dispatch = useDispatch();
12 | const { totalPrice, items } = useSelector(selectCart);
13 |
14 | const totalCount = items.reduce((sum: number, item: any) => sum + item.count, 0);
15 |
16 | const onClickClear = () => {
17 | if (window.confirm('Очистить корзину?')) {
18 | dispatch(clearItems());
19 | }
20 | };
21 |
22 | if (!totalPrice) {
23 | return ;
24 | }
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
56 | Корзина
57 |
58 |
59 |
90 |
91 |
Очистить корзину
92 |
93 |
94 |
95 | {items.map((item: any) => (
96 |
97 | ))}
98 |
99 |
100 |
101 |
102 | {' '}
103 | Всего пицц: {totalCount} шт.{' '}
104 |
105 |
106 | {' '}
107 | Сумма заказа: {totalPrice} ₽{' '}
108 |
109 |
110 |
111 |
112 |
125 |
126 |
Вернуться назад
127 |
128 |
129 | Оплатить сейчас
130 |
131 |
132 |
133 |
134 |
135 | );
136 | };
137 |
138 | export default Cart;
139 |
--------------------------------------------------------------------------------
/src/pages/FullPizza.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import axios from 'axios';
4 | import { useParams, useNavigate } from 'react-router-dom';
5 |
6 | const FullPizza: React.FC = () => {
7 | const [pizza, setPizza] = React.useState<{
8 | imageUrl: string;
9 | title: string;
10 | price: number;
11 | }>();
12 |
13 | const { id } = useParams();
14 | const navigate = useNavigate();
15 |
16 | React.useEffect(() => {
17 | async function fetchPizza() {
18 | try {
19 | const { data } = await axios.get('https://626d16545267c14d5677d9c2.mockapi.io/items/' + id);
20 | setPizza(data);
21 | } catch (error) {
22 | alert('Ошибка при получении пиццы!');
23 | navigate('/');
24 | }
25 | }
26 |
27 | fetchPizza();
28 | }, []);
29 |
30 | if (!pizza) {
31 | return <>Загрузка...>;
32 | }
33 |
34 | return (
35 |
36 |

37 |
{pizza.title}
38 |
{pizza.price} ₽
39 |
40 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default FullPizza;
49 |
--------------------------------------------------------------------------------
/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import qs from 'qs';
3 | import { useSelector } from 'react-redux';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { Categories, Sort, PizzaBlock, Skeleton, Pagination } from '../components';
7 |
8 | import { sortList } from '../components/Sort';
9 |
10 | import { useAppDispatch } from '../redux/store';
11 | import { selectFilter } from '../redux/filter/selectors';
12 | import { selectPizzaData } from '../redux/pizza/selectors';
13 | import { setCategoryId, setCurrentPage, setFilters } from '../redux/filter/slice';
14 | import { fetchPizzas } from '../redux/pizza/asyncActions';
15 | import { SearchPizzaParams } from '../redux/pizza/types';
16 |
17 | const Home: React.FC = () => {
18 | const navigate = useNavigate();
19 | const dispatch = useAppDispatch();
20 | const isMounted = React.useRef(false);
21 |
22 | const { items, status } = useSelector(selectPizzaData);
23 | const { categoryId, sort, currentPage, searchValue } = useSelector(selectFilter);
24 |
25 | const onChangeCategory = React.useCallback((idx: number) => {
26 | dispatch(setCategoryId(idx));
27 | }, []);
28 |
29 | const onChangePage = (page: number) => {
30 | dispatch(setCurrentPage(page));
31 | };
32 |
33 | const getPizzas = async () => {
34 | const sortBy = sort.sortProperty.replace('-', '');
35 | const order = sort.sortProperty.includes('-') ? 'asc' : 'desc';
36 | const category = categoryId > 0 ? String(categoryId) : '';
37 | const search = searchValue;
38 |
39 | dispatch(
40 | fetchPizzas({
41 | sortBy,
42 | order,
43 | category,
44 | search,
45 | currentPage: String(currentPage),
46 | }),
47 | );
48 |
49 | window.scrollTo(0, 0);
50 | };
51 |
52 | // Если изменили параметры и был первый рендер
53 | React.useEffect(() => {
54 | // if (isMounted.current) {
55 | // const params = {
56 | // categoryId: categoryId > 0 ? categoryId : null,
57 | // sortProperty: sort.sortProperty,
58 | // currentPage,
59 | // };
60 |
61 | // const queryString = qs.stringify(params, { skipNulls: true });
62 |
63 | // navigate(`/?${queryString}`);
64 | // }
65 |
66 | // const params = qs.parse(window.location.search.substring(1)) as unknown as SearchPizzaParams;
67 | // const sortObj = sortList.find((obj) => obj.sortProperty === params.sortBy);
68 | // dispatch(
69 | // setFilters({
70 | // searchValue: params.search,
71 | // categoryId: Number(params.category),
72 | // currentPage: Number(params.currentPage),
73 | // sort: sortObj || sortList[0],
74 | // }),
75 | // );
76 |
77 | getPizzas();
78 | // isMounted.current = true;
79 | }, [categoryId, sort.sortProperty, searchValue, currentPage]);
80 |
81 | // Парсим параметры при первом рендере
82 | // React.useEffect(() => {
83 | // if (window.location.search) {
84 | // const params = qs.parse(window.location.search.substring(1)) as unknown as SearchPizzaParams;
85 | // const sort = sortList.find((obj) => obj.sortProperty === params.sortBy);
86 | // dispatch(
87 | // setFilters({
88 | // searchValue: params.search,
89 | // categoryId: Number(params.category),
90 | // currentPage: Number(params.currentPage),
91 | // sort: sort || sortList[0],
92 | // }),
93 | // );
94 | // }
95 | // isMounted.current = true;
96 | // }, []);
97 |
98 | const pizzas = items.map((obj: any) => );
99 | const skeletons = [...new Array(6)].map((_, index) => );
100 |
101 | return (
102 |
103 |
104 |
105 |
106 |
107 |
Все пиццы
108 | {status === 'error' ? (
109 |
110 |
Произошла ошибка 😕
111 |
К сожалению, не удалось получить питсы. Попробуйте повторить попытку позже.
112 |
113 | ) : (
114 |
{status === 'loading' ? skeletons : pizzas}
115 | )}
116 |
117 |
118 |
119 | );
120 | };
121 |
122 | export default Home;
123 |
--------------------------------------------------------------------------------
/src/pages/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NotFoundBlock } from '../components';
3 |
4 | const NotFound: React.FC = () => ;
5 |
6 | export default NotFound;
7 |
--------------------------------------------------------------------------------
/src/redux/cart/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '../store';
2 |
3 | export const selectCart = (state: RootState) => state.cart;
4 |
5 | export const selectCartItemById = (id: string) => (state: RootState) =>
6 | state.cart.items.find((obj) => obj.id === id);
7 |
--------------------------------------------------------------------------------
/src/redux/cart/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { calcTotalPrice } from '../../utils/calcTotalPrice';
3 | import { getCartFromLS } from '../../utils/getCartFromLS';
4 | import { CartItem, CartSliceState } from './types';
5 |
6 | const initialState: CartSliceState = getCartFromLS();
7 |
8 | const cartSlice = createSlice({
9 | name: 'cart',
10 | initialState,
11 | reducers: {
12 | addItem(state, action: PayloadAction) {
13 | const findItem = state.items.find((obj) => obj.id === action.payload.id);
14 |
15 | if (findItem) {
16 | findItem.count++;
17 | } else {
18 | state.items.push({
19 | ...action.payload,
20 | count: 1,
21 | });
22 | }
23 |
24 | state.totalPrice = calcTotalPrice(state.items);
25 | },
26 | minusItem(state, action: PayloadAction) {
27 | const findItem = state.items.find((obj) => obj.id === action.payload);
28 |
29 | if (findItem) {
30 | findItem.count--;
31 | }
32 |
33 | state.totalPrice = calcTotalPrice(state.items);
34 | },
35 | removeItem(state, action: PayloadAction) {
36 | state.items = state.items.filter((obj) => obj.id !== action.payload);
37 | state.totalPrice = calcTotalPrice(state.items);
38 | },
39 | clearItems(state) {
40 | state.items = [];
41 | state.totalPrice = 0;
42 | },
43 | },
44 | });
45 |
46 | export const { addItem, removeItem, minusItem, clearItems } = cartSlice.actions;
47 |
48 | export default cartSlice.reducer;
49 |
--------------------------------------------------------------------------------
/src/redux/cart/types.ts:
--------------------------------------------------------------------------------
1 | export type CartItem = {
2 | id: string;
3 | title: string;
4 | price: number;
5 | imageUrl: string;
6 | type: string;
7 | size: number;
8 | count: number;
9 | };
10 |
11 | export interface CartSliceState {
12 | totalPrice: number;
13 | items: CartItem[];
14 | }
15 |
--------------------------------------------------------------------------------
/src/redux/filter/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '../store';
2 |
3 | export const selectFilter = (state: RootState) => state.filter;
4 | export const selectSort = (state: RootState) => state.filter.sort;
5 |
--------------------------------------------------------------------------------
/src/redux/filter/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { FilterSliceState, Sort, SortPropertyEnum } from './types';
3 |
4 | const initialState: FilterSliceState = {
5 | searchValue: '',
6 | categoryId: 0,
7 | currentPage: 1,
8 | sort: {
9 | name: 'популярности',
10 | sortProperty: SortPropertyEnum.RATING_DESC,
11 | },
12 | };
13 |
14 | const filterSlice = createSlice({
15 | name: 'filters',
16 | initialState,
17 | reducers: {
18 | setCategoryId(state, action: PayloadAction) {
19 | state.categoryId = action.payload;
20 | },
21 | setSearchValue(state, action: PayloadAction) {
22 | state.searchValue = action.payload;
23 | },
24 | setSort(state, action: PayloadAction) {
25 | state.sort = action.payload;
26 | },
27 | setCurrentPage(state, action: PayloadAction) {
28 | state.currentPage = action.payload;
29 | },
30 | setFilters(state, action: PayloadAction) {
31 | if (Object.keys(action.payload).length) {
32 | state.currentPage = Number(action.payload.currentPage);
33 | state.categoryId = Number(action.payload.categoryId);
34 | state.sort = action.payload.sort;
35 | } else {
36 | state.currentPage = 1;
37 | state.categoryId = 0;
38 | state.sort = {
39 | name: 'популярности',
40 | sortProperty: SortPropertyEnum.RATING_DESC,
41 | };
42 | }
43 | },
44 | },
45 | });
46 |
47 | export const { setCategoryId, setSort, setCurrentPage, setFilters, setSearchValue } =
48 | filterSlice.actions;
49 |
50 | export default filterSlice.reducer;
51 |
--------------------------------------------------------------------------------
/src/redux/filter/types.ts:
--------------------------------------------------------------------------------
1 | export enum SortPropertyEnum {
2 | RATING_DESC = 'rating',
3 | RATING_ASC = '-rating',
4 | TITLE_DESC = 'title',
5 | TITLE_ASC = '-title',
6 | PRICE_DESC = 'price',
7 | PRICE_ASC = '-price',
8 | }
9 |
10 | export type Sort = {
11 | name: string;
12 | sortProperty: SortPropertyEnum;
13 | };
14 |
15 | export interface FilterSliceState {
16 | searchValue: string;
17 | categoryId: number;
18 | currentPage: number;
19 | sort: Sort;
20 | }
21 |
--------------------------------------------------------------------------------
/src/redux/pizza/asyncActions.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 | import axios from 'axios';
3 | import { Pizza, SearchPizzaParams } from './types';
4 | import pickBy from 'lodash/pickBy';
5 | import identity from 'lodash/identity';
6 |
7 | export const fetchPizzas = createAsyncThunk(
8 | 'pizza/fetchPizzasStatus',
9 | async (params) => {
10 | const { sortBy, order, category, search, currentPage } = params;
11 | console.log(params, 4444);
12 | const { data } = await axios.get(`https://626d16545267c14d5677d9c2.mockapi.io/items`, {
13 | params: pickBy(
14 | {
15 | page: currentPage,
16 | limit: 4,
17 | category,
18 | sortBy,
19 | order,
20 | search,
21 | },
22 | identity,
23 | ),
24 | });
25 |
26 | return data;
27 | },
28 | );
29 |
--------------------------------------------------------------------------------
/src/redux/pizza/selectors.ts:
--------------------------------------------------------------------------------
1 | import { RootState } from '../store';
2 |
3 | export const selectPizzaData = (state: RootState) => state.pizza;
4 |
--------------------------------------------------------------------------------
/src/redux/pizza/slice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { fetchPizzas } from './asyncActions';
3 | import { Pizza, PizzaSliceState, Status } from './types';
4 |
5 | const initialState: PizzaSliceState = {
6 | items: [],
7 | status: Status.LOADING, // loading | success | error
8 | };
9 |
10 | const pizzaSlice = createSlice({
11 | name: 'pizza',
12 | initialState,
13 | reducers: {
14 | setItems(state, action: PayloadAction) {
15 | state.items = action.payload;
16 | },
17 | },
18 | extraReducers: (builder) => {
19 | builder.addCase(fetchPizzas.pending, (state, action) => {
20 | state.status = Status.LOADING;
21 | state.items = [];
22 | });
23 |
24 | builder.addCase(fetchPizzas.fulfilled, (state, action) => {
25 | state.items = action.payload;
26 | state.status = Status.SUCCESS;
27 | });
28 |
29 | builder.addCase(fetchPizzas.rejected, (state, action) => {
30 | state.status = Status.ERROR;
31 | state.items = [];
32 | });
33 | },
34 | });
35 |
36 | export const { setItems } = pizzaSlice.actions;
37 |
38 | export default pizzaSlice.reducer;
39 |
--------------------------------------------------------------------------------
/src/redux/pizza/types.ts:
--------------------------------------------------------------------------------
1 | export type Pizza = {
2 | id: string;
3 | title: string;
4 | price: number;
5 | imageUrl: string;
6 | sizes: number[];
7 | types: number[];
8 | rating: number;
9 | };
10 |
11 | export enum Status {
12 | LOADING = 'loading',
13 | SUCCESS = 'completed',
14 | ERROR = 'error',
15 | }
16 |
17 | export type SearchPizzaParams = {
18 | sortBy: string;
19 | order: string;
20 | category: string;
21 | search: string;
22 | currentPage: string;
23 | };
24 |
25 | export interface PizzaSliceState {
26 | items: Pizza[];
27 | status: Status;
28 | }
29 |
--------------------------------------------------------------------------------
/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import filter from './filter/slice';
3 | import cart from './cart/slice';
4 | import pizza from './pizza/slice';
5 | import { useDispatch } from 'react-redux';
6 |
7 | export const store = configureStore({
8 | reducer: {
9 | filter,
10 | cart,
11 | pizza,
12 | },
13 | });
14 |
15 | export type RootState = ReturnType;
16 |
17 | type AppDispatch = typeof store.dispatch;
18 |
19 | export const useAppDispatch = () => useDispatch();
20 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/scss/_variables.scss:
--------------------------------------------------------------------------------
1 | $black: #232323;
2 | $background: #ffdf8c;
3 | $gray-line: #f6f6f6;
4 | $orange: #fe5f1e;
5 |
6 | $container-width: 90%;
7 |
8 | $duration: 0.15s;
9 |
--------------------------------------------------------------------------------
/src/scss/app.scss:
--------------------------------------------------------------------------------
1 | @import './variables';
2 | @import './libs/normalize';
3 |
4 | @import './components/all';
5 |
6 | body {
7 | background-color: $background;
8 | }
9 |
10 | .wrapper {
11 | width: calc(100vw - 100px);
12 | height: 100%;
13 | background-color: #fff;
14 | margin: 50px auto;
15 | border-radius: 10px;
16 | max-width: 1400px;
17 |
18 | @media screen and (max-width: 645px) {
19 | width: calc(100vw - 30px);
20 | margin-top: 15px;
21 | }
22 | }
23 |
24 | .content {
25 | padding: 40px 0;
26 |
27 | &__title {
28 | font-weight: 800;
29 | font-size: 38px;
30 | margin: 60px 0;
31 | }
32 |
33 | &__items {
34 | display: grid;
35 | grid-template-columns: repeat(4, 1fr);
36 | grid-template-rows: repeat(auto-fit, 1fr);
37 | grid-column-gap: 11px;
38 |
39 | @media screen and (max-width: 1400px) {
40 | grid-template-columns: repeat(3, 1fr);
41 |
42 | @media screen and (max-width: 1060px) {
43 | grid-template-columns: repeat(2, 1fr);
44 | }
45 |
46 | @media screen and (max-width: 730px) {
47 | grid-template-columns: repeat(1, 1fr);
48 | }
49 | }
50 | }
51 |
52 | &__top {
53 | display: flex;
54 | align-items: center;
55 | justify-content: space-between;
56 |
57 | @media screen and (max-width: 1260px) {
58 | flex-direction: column;
59 |
60 | .categories {
61 | margin-bottom: 30px;
62 | width: 100%;
63 | overflow: auto;
64 | }
65 |
66 | ul {
67 | width: 785px;
68 | }
69 | }
70 |
71 | @media screen and (max-width: 390px) {
72 | flex-direction: column;
73 |
74 | .categories {
75 | margin-bottom: 30px;
76 | width: 100%;
77 | overflow: scroll;
78 | }
79 |
80 | ul {
81 | li {
82 | padding: 8px 20px;
83 | }
84 | }
85 | }
86 |
87 | }
88 | }
89 |
90 | .container {
91 | width: $container-width;
92 | margin: 0 auto;
93 |
94 | &--cart {
95 | max-width: 820px;
96 | margin: 90px auto;
97 | .content__title {
98 | margin: 0;
99 | }
100 | }
101 | }
102 |
103 | .cart {
104 | &__top {
105 | display: flex;
106 | justify-content: space-between;
107 | align-items: center;
108 | }
109 |
110 | .content__title {
111 | display: flex;
112 | align-items: center;
113 | font-size: 32px;
114 |
115 | svg {
116 | position: relative;
117 | top: -2px;
118 | width: 30px;
119 | height: 30px;
120 | margin-right: 10px;
121 | path {
122 | stroke: $black;
123 | stroke-width: 1.9;
124 | }
125 | }
126 | }
127 |
128 | &__clear {
129 | display: flex;
130 | align-items: center;
131 | cursor: pointer;
132 | @include noselect();
133 |
134 | span {
135 | display: inline-block;
136 | margin-left: 7px;
137 | color: #b6b6b6;
138 | font-size: 18px;
139 | }
140 |
141 | span,
142 | svg,
143 | path {
144 | transition: all $duration ease-in-out;
145 | }
146 |
147 | &:hover {
148 | svg {
149 | path {
150 | stroke: darken($color: #b6b6b6, $amount: 50);
151 | }
152 | }
153 | span {
154 | color: darken($color: #b6b6b6, $amount: 50);
155 | }
156 | }
157 | }
158 |
159 | .content__items {
160 | display: block;
161 | }
162 |
163 | &__item {
164 | display: flex;
165 | width: 100%;
166 | border-top: 1px solid $gray-line;
167 | padding-top: 30px;
168 | margin-top: 30px;
169 |
170 | &-img {
171 | display: flex;
172 | align-items: center;
173 | margin-right: 15px;
174 | width: 10%;
175 |
176 | img {
177 | width: 80px;
178 | height: 80px;
179 | }
180 | }
181 |
182 | &-info {
183 | display: flex;
184 | flex-direction: column;
185 | justify-content: center;
186 | width: 40%;
187 |
188 | h3 {
189 | font-weight: bold;
190 | font-size: 22px;
191 | line-height: 27px;
192 | letter-spacing: 0.01em;
193 | }
194 |
195 | p {
196 | font-size: 18px;
197 | color: #8d8d8d;
198 | }
199 | }
200 |
201 | &-count {
202 | display: flex;
203 | align-items: center;
204 | justify-content: space-between;
205 | width: 13%;
206 |
207 | &-minus {
208 | svg {
209 | path:first-of-type {
210 | display: none;
211 | }
212 | }
213 | }
214 |
215 | b {
216 | font-size: 22px;
217 | }
218 | }
219 |
220 | &-price {
221 | display: flex;
222 | align-items: center;
223 | justify-content: center;
224 | width: 33%;
225 |
226 | b {
227 | font-weight: bold;
228 | font-size: 22px;
229 | letter-spacing: 0.01em;
230 | }
231 | }
232 |
233 | &-remove {
234 | display: flex;
235 | align-items: center;
236 | justify-content: flex-end;
237 | width: 4%;
238 |
239 | .button {
240 | border-color: darken($color: $gray-line, $amount: 10);
241 | }
242 |
243 | svg {
244 | transform: rotate(45deg);
245 |
246 | path {
247 | fill: darken($color: $gray-line, $amount: 15);
248 | }
249 | }
250 |
251 | .button {
252 | svg {
253 | width: 11.5px;
254 | height: 11.5px;
255 | position: relative;
256 | }
257 | &:hover,
258 | &:active {
259 | border-color: darken($color: $gray-line, $amount: 80);
260 | background-color: darken($color: $gray-line, $amount: 80);
261 | }
262 | }
263 | }
264 | }
265 |
266 | &__bottom {
267 | margin: 50px 0;
268 |
269 | &-details {
270 | display: flex;
271 | justify-content: space-between;
272 |
273 | span {
274 | font-size: 22px;
275 |
276 | &:last-of-type {
277 | b {
278 | color: $orange;
279 | }
280 | }
281 | }
282 | }
283 |
284 | &-buttons {
285 | display: flex;
286 | justify-content: space-between;
287 | margin-top: 40px;
288 |
289 | .go-back-btn {
290 | display: flex;
291 | align-items: center;
292 | justify-content: center;
293 | width: 210px;
294 |
295 | border-color: darken($color: $gray-line, $amount: 10);
296 |
297 | span {
298 | color: darken($color: $gray-line, $amount: 20);
299 | font-weight: 500;
300 | font-weight: 600;
301 | }
302 |
303 | &:hover {
304 | background-color: darken($color: $gray-line, $amount: 90);
305 | border-color: darken($color: $gray-line, $amount: 90);
306 |
307 | span {
308 | color: $gray-line;
309 | }
310 | }
311 |
312 | svg {
313 | margin-right: 12px;
314 | path {
315 | fill: transparent;
316 | stroke-width: 2;
317 | }
318 | }
319 | }
320 |
321 | .pay-btn {
322 | font-size: 16px;
323 | font-weight: 600;
324 | width: 210px;
325 | padding: 16px;
326 | }
327 | }
328 | }
329 |
330 | &--empty {
331 | margin: 0 auto;
332 | width: 560px;
333 | text-align: center;
334 | @media screen and (max-width: 767px) {
335 | width: 300px;
336 | }
337 |
338 | h2 {
339 | font-size: 32px;
340 | margin-bottom: 10px;
341 | }
342 |
343 | p {
344 | font-size: 18px;
345 | line-height: 145.4%;
346 | letter-spacing: 0.01em;
347 | color: #777777;
348 | }
349 |
350 | icon {
351 | position: relative;
352 | top: 2px;
353 | }
354 |
355 | img {
356 | display: block;
357 | width: 300px;
358 | margin: 45px auto 60px;
359 |
360 | @media screen and (max-width: 767px) {
361 | width: 150px;
362 | }
363 | }
364 |
365 | .button--black {
366 | padding: 12px 0 14px;
367 | width: 230px;
368 | margin: 0 auto;
369 | font-weight: 600;
370 | font-size: 18px;
371 | }
372 | }
373 | }
374 |
--------------------------------------------------------------------------------
/src/scss/components/_all.scss:
--------------------------------------------------------------------------------
1 | @import './header';
2 | @import './button';
3 | @import './categories';
4 | @import './sort';
5 | @import './pizza-block';
6 |
--------------------------------------------------------------------------------
/src/scss/components/_button.scss:
--------------------------------------------------------------------------------
1 | @import '../variables';
2 |
3 | .button {
4 | display: inline-block;
5 | background-color: $orange;
6 | border-radius: 30px;
7 | padding: 10px 20px;
8 | min-width: 100px;
9 | text-align: center;
10 | cursor: pointer;
11 | transition: background-color $duration ease-in-out, border-color $duration ease-in-out;
12 | border: 1px solid transparent;
13 | @include noselect();
14 |
15 | &,
16 | span {
17 | color: #fff;
18 | }
19 |
20 | i,
21 | span,
22 | path,
23 | svg {
24 | transition: all $duration ease-in-out;
25 | }
26 |
27 | &:hover {
28 | background-color: darken($orange, 8%);
29 | }
30 |
31 | &:active {
32 | background-color: darken($orange, 12%);
33 | transform: translateY(1px);
34 | }
35 |
36 | &--circle {
37 | display: flex;
38 | align-items: center;
39 | justify-content: center;
40 | width: 32px;
41 | height: 32px;
42 | min-width: 32px;
43 | padding: 0;
44 | border-width: 2px;
45 | }
46 |
47 | &--black {
48 | background-color: $black;
49 |
50 | &:hover,
51 | &:active {
52 | background-color: lighten($color: $black, $amount: 10);
53 | }
54 | }
55 |
56 | &--outline {
57 | background-color: #fff;
58 | border-color: $orange;
59 | &,
60 | span {
61 | color: $orange;
62 | }
63 |
64 | svg {
65 | path {
66 | fill: $orange;
67 | }
68 | }
69 |
70 | &:hover {
71 | background-color: $orange;
72 |
73 | &,
74 | span {
75 | color: #fff;
76 | }
77 |
78 | svg {
79 | path {
80 | fill: #fff;
81 | }
82 | }
83 | }
84 |
85 | &:active {
86 | background-color: darken($orange, 8%);
87 | }
88 | }
89 |
90 | &__delimiter {
91 | width: 1px;
92 | height: 25px;
93 | background-color: rgba(255, 255, 255, 0.25);
94 | margin-left: 14px;
95 | margin-right: 14px;
96 | }
97 |
98 | &--add {
99 | svg {
100 | margin-right: 2px;
101 | }
102 |
103 | span {
104 | font-weight: 600;
105 | font-size: 16px;
106 | }
107 |
108 | &:hover {
109 | i {
110 | background-color: #fff;
111 | color: $orange;
112 | }
113 | }
114 |
115 | i {
116 | display: inline-block;
117 | border-radius: 30px;
118 | background-color: $orange;
119 | color: #fff;
120 | font-weight: 600;
121 | width: 22px;
122 | height: 22px;
123 | font-style: normal;
124 | font-size: 13px;
125 | line-height: 22px;
126 | position: relative;
127 | top: -1px;
128 | left: 3px;
129 | }
130 | }
131 |
132 | &--cart {
133 | display: flex;
134 | align-items: center;
135 | line-height: 23px;
136 | padding: 12px 25px;
137 |
138 | svg {
139 | margin-right: 8px;
140 | margin-bottom: 1px;
141 | }
142 |
143 | span {
144 | font-weight: 600;
145 | font-size: 16px;
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/scss/components/_categories.scss:
--------------------------------------------------------------------------------
1 | @import '../variables';
2 |
3 | .categories {
4 | ul {
5 | display: flex;
6 |
7 | li {
8 | background-color: #f9f9f9;
9 | padding: 13px 30px;
10 | border-radius: 30px;
11 | margin-right: 10px;
12 | font-weight: bold;
13 | cursor: pointer;
14 | transition: background-color 0.1s ease-in-out;
15 | @include noselect();
16 |
17 | &:hover {
18 | background-color: darken(#f9f9f9, 2%);
19 | }
20 |
21 | &:active {
22 | background-color: darken(#f9f9f9, 5%);
23 | }
24 |
25 | &.active {
26 | background-color: #282828;
27 | color: #fff;
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/scss/components/_header.scss:
--------------------------------------------------------------------------------
1 | @import '../variables';
2 |
3 | .header {
4 | border-bottom: 1px solid $gray-line;
5 | padding: 40px 0;
6 |
7 | .container {
8 | display: flex;
9 | align-items: center;
10 | justify-content: space-between;
11 |
12 | @media screen and (max-width: 645px) {
13 | flex-direction: column;
14 |
15 | .header__cart {
16 | margin-top: 20px;
17 | }
18 | }
19 |
20 | }
21 |
22 | &__logo {
23 | display: flex;
24 |
25 | img {
26 | margin-right: 15px;
27 | }
28 |
29 | h1 {
30 | color: #181818;
31 | font-size: 24px;
32 | text-transform: uppercase;
33 | font-weight: 800;
34 | text-align: left;
35 | }
36 |
37 | p {
38 | color: #7b7b7b;
39 | text-align: left;
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/scss/components/_pizza-block.scss:
--------------------------------------------------------------------------------
1 | @import '../variables';
2 |
3 | .pizza-block {
4 | width: 280px;
5 | text-align: center;
6 | margin-bottom: 65px;
7 |
8 | &-wrapper {
9 | display: flex;
10 | justify-content: center;
11 | }
12 |
13 | &__image {
14 | width: 260px;
15 | }
16 |
17 | &__title {
18 | font-size: 20px;
19 | font-weight: 900;
20 | margin-bottom: 20px;
21 | }
22 |
23 | &__selector {
24 | display: flex;
25 | background-color: #f3f3f3;
26 | border-radius: 10px;
27 | flex-direction: column;
28 | padding: 6px;
29 |
30 | ul {
31 | display: flex;
32 | flex: 1;
33 |
34 | &:first-of-type {
35 | margin-bottom: 6px;
36 | }
37 |
38 | li {
39 | padding: 8px;
40 | flex: 1;
41 | cursor: pointer;
42 | font-weight: 600;
43 | font-size: 14px;
44 | @include noselect();
45 | &.active {
46 | background: #ffffff;
47 | box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.04);
48 | border-radius: 5px;
49 | cursor: auto;
50 | }
51 | }
52 | }
53 | }
54 |
55 | &__bottom {
56 | display: flex;
57 | align-items: center;
58 | justify-content: space-between;
59 | margin-top: 20px;
60 | }
61 |
62 | &__price {
63 | font-weight: bold;
64 | font-size: 22px;
65 | line-height: 27px;
66 | letter-spacing: 0.015em;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/scss/components/_sort.scss:
--------------------------------------------------------------------------------
1 | @import '../variables';
2 |
3 | .sort {
4 | position: relative;
5 | &__label {
6 | display: flex;
7 | align-items: center;
8 |
9 | svg {
10 | margin-right: 8px;
11 | }
12 |
13 | b {
14 | margin-right: 8px;
15 | }
16 |
17 | span {
18 | color: $orange;
19 | border-bottom: 1px dashed $orange;
20 | cursor: pointer;
21 | }
22 | }
23 |
24 | &__popup {
25 | position: absolute;
26 | right: 0;
27 | margin-top: 15px;
28 | background: #ffffff;
29 | box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.09);
30 | border-radius: 10px;
31 | overflow: hidden;
32 | padding: 10px 0;
33 | width: 160px;
34 |
35 | ul {
36 | overflow: hidden;
37 | li {
38 | padding: 12px 20px;
39 | cursor: pointer;
40 |
41 | &.active,
42 | &:hover {
43 | background: rgba(254, 95, 30, 0.05);
44 | }
45 |
46 | &.active {
47 | font-weight: bold;
48 | color: $orange;
49 | }
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/scss/libs/_normalize.scss:
--------------------------------------------------------------------------------
1 | * {
2 | padding: 0;
3 | margin: 0;
4 | list-style: none;
5 | outline: none;
6 | font-family: 'Nunito', Roboto, system-ui, Tahoma, sans-serif;
7 | box-sizing: border-box;
8 | }
9 |
10 | html {
11 | -ms-text-size-adjust: 100%;
12 | -webkit-text-size-adjust: 100%;
13 | }
14 |
15 | body {
16 | -moz-osx-font-smoothing: grayscale;
17 | -webkit-font-smoothing: antialiased;
18 | color: $black;
19 | }
20 |
21 | a,
22 | span,
23 | p,
24 | b,
25 | h1,
26 | h2,
27 | h3,
28 | h4,
29 | h5 {
30 | color: $black;
31 | }
32 |
33 | h1 {
34 | font-size: 48px;
35 | }
36 |
37 | h2 {
38 | font-weight: 600;
39 | font-size: 28px;
40 | line-height: 30px;
41 | }
42 |
43 | a {
44 | text-decoration: none;
45 | }
46 |
47 | @mixin noselect {
48 | -webkit-touch-callout: none; /* iOS Safari */
49 | -webkit-user-select: none; /* Safari */
50 | -khtml-user-select: none; /* Konqueror HTML */
51 | -moz-user-select: none; /* Old versions of Firefox */
52 | -ms-user-select: none; /* Internet Explorer/Edge */
53 | user-select: none; /* Non-prefixed version, currently
54 | supported by Chrome, Opera and Firefox */
55 | }
56 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/utils/calcTotalPrice.ts:
--------------------------------------------------------------------------------
1 | import { CartItem } from '../redux/cart/types';
2 |
3 | export const calcTotalPrice = (items: CartItem[]) => {
4 | return items.reduce((sum, obj) => obj.price * obj.count + sum, 0);
5 | };
6 |
--------------------------------------------------------------------------------
/src/utils/getCartFromLS.ts:
--------------------------------------------------------------------------------
1 | import { CartItem } from '../redux/cart/types';
2 | import { calcTotalPrice } from './calcTotalPrice';
3 |
4 | export const getCartFromLS = () => {
5 | const data = localStorage.getItem('cart');
6 | const items = data ? JSON.parse(data) : [];
7 | const totalPrice = calcTotalPrice(items);
8 |
9 | return {
10 | items: items as CartItem[],
11 | totalPrice,
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/src/utils/math.ts:
--------------------------------------------------------------------------------
1 | export function add(a: number, b: number) {
2 | console.log(111);
3 | return a + b;
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------