React.js. Витрина интернет-магазина и корзина
20.08.2021
Теги: Frontend • JavaScript • React.js • Web-разработка • ИнтернетМагазин • КаталогТоваров • Компонент • Корзина • Практика
Небольшое приложение — витрина интернет-магазина + корзина покупателя, не имеет практической ценности, сделано исключительно с целью изучения React. Для оформления используется css-фреймворк materialize.css, http-запросы на получение списка товаров отправляются сервису fortniteapi.io. Чтобы отправлять запросы на получение списка товаров — нужно получить api-ключ, это бесплатно. Итак, разворачиваем react-приложение.
> npx create-react-app .
Подготовительные работы
Все лишнее из директории src
удалим, оставим только index.js
, index.css
и App.js
:
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') );
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
#root {
/* flex-контейнер для <header>, <main> и <footer> */
display: flex;
/* flex-элементы <header>, <main> и <footer> выстаиваются по вертикали */
flex-direction: column;
/* вся высота viewport браузера */
min-height: 100vh;
}
main {
padding-top: 20px;
/*
* может как увеличиваться, так и уменьшаться, чтобы вместе с
* <header> и <footer> занять всю высоту viewport браузера
*/
flex: 1 1 auto;
}
.page-footer {
padding-top: 10px;
padding-bottom: 10px;
}
nav {
box-shadow: none;
}
import React from 'react'; import Header from './components/Header'; import Footer from './components/Footer'; import Content from './components/Content'; export default function App() { return ( <React.Fragment> <Header /> <Content /> <Footer /> </React.Fragment> ); }
Создадим директорию components
и разместим в ней три компонента — Header.js
, Footer.js
и Content.js
.
export default function Header() { return ( <header> <nav className="teal lighten-1"> <div className="nav-wrapper container"> <a href="#" className="brand-logo">React Shop</a> <ul id="nav-mobile" className="right hide-on-med-and-down"> <li><a href="#">Link one</a></li> <li><a href="#">Link two</a></li> </ul> </div> </nav> </header> ); }
export default function Footer() { return ( <footer className="page-footer teal lighten-1"> <div className="container"> © {new Date().getFullYear()} All rights reserved </div> </footer> ); }
export default function Content() { return ( <main className="container"> <h1>React Shop</h1> </main> ); }
В файле public/index.html
подключим css-фреймворк:
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="utf-8" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> <meta name="description" content="Simple React Shop" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <title>React Shop</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> </body> </html>
Работа с сервисом FortniteAPI
Для изучения API потребуется приложение Postman, его можно скачать бесплатно. Запрос на получение списка товаров:
https://fortniteapi.io/v2/items/list?lang=ru
{ "result": true, "items": [ { "id": "CID_267_Athena_Commando_M_RobotRed", "type": { "id": "outfit", "name": "Экипировка" }, "name": "Б.О.Т.", "description": "Боевой опасный терминатор.", "rarity": { "id": "Legendary", "name": "ЛЕГЕНДАРНЫЙ" }, "series": null, "price": 0, "added": { "date": "2018-09-27", "version": "6.0" }, "builtInEmote": null, "copyrightedAudio": false, "upcoming": false, "reactive": false, "releaseDate": null, "lastAppearance": null, "interest": 0.27, "images": { "icon": "https://media.fortniteapi.io/images/552cd39-3307b68-2aef1e3-414c094/transparent.png", "featured": null, "background": "https://media.fortniteapi.io/images/cosmetics/552cd39-3307b68-2aef1e3-414c094/v2/background.png", "full_background": "https://media.fortniteapi.io/images/cosmetics/552cd39-3307b68-2aef1e3-414c094/v2/info.ru.png" }, "video": null, "audio": null, "gameplayTags": [ "Cosmetics.Source.Season6.BattlePass.Paid", "Cosmetics.Set.RobotRed", "Cosmetics.Filter.Season.6" ], "apiTags": [], "battlepass": null, "set": { "id": "RobotRed", "name": "БОТ", "partOf": "Входит в набор «БОТ»." } }, .......... ] }
Запрос подробной информации о товаре по идентификатору id
:
https://fortniteapi.io/v2/items/get?lang=ru&id=CID_748_Athena_Commando_F_Hitman
{ "result": true, "item": { "id": "CID_748_Athena_Commando_F_Hitman", "type": { "id": "outfit", "name": "Экипировка" }, "name": "Сирена", "description": "Красота требует жертв.", "rarity": { "id": "Rare", "name": "РЕДКИЙ" }, "series": null, "price": 1200, "added": { "date": "2020-04-15", "version": "12.40" }, "builtInEmote": null, "copyrightedAudio": false, "upcoming": false, "reactive": false, "releaseDate": "2020-04-18", "lastAppearance": "2021-08-16", "interest": 4.96, "images": { "icon": "https://media.fortniteapi.io/images/3f3824cdbbe5ff412907572724f8fd5a/transparent.png", "featured": "https://media.fortniteapi.io/images/3f3824cdbbe5ff412907572724f8fd5a/full_featured.png", "background": "https://media.fortniteapi.io/images/cosmetics/3f3824cdbbe5ff412907572724f8fd5a/v2/background.png", "full_background": "https://media.fortniteapi.io/images/cosmetics/3f3824cdbbe5ff412907572724f8fd5a/v2/info.ru.png" }, "video": null, "audio": null, "gameplayTags": [ "Cosmetics.Source.ItemShop", "Cosmetics.Filter.Season.12", "Cosmetics.Set.BonkTeam", "Cosmetics.UserFacingFlags.HasVariants" ], "apiTags": [], "battlepass": null, "set": { "id": "BonkTeam", "name": "Опасная авантюра", "partOf": "Входит в набор «Опасная авантюра»." }, "introduction": { "chapter": "Глава 2", "season": "Сезон 2", "text": "Первое появление: Глава 2, Сезон 2." }, "displayAssets": [ { "displayAsset": "DAv2_CID_748_F_Hitman", "materialInstance": "MI_CID_748_F_Hitman", "url": "https://media.fortniteapi.io/images/displayAssets/v2/DAv2_CID_748_F_Hitman/MI_CID_748_F_Hitman.png", "flipbook": null, "background": "https://media.fortniteapi.io/images/cosmetics/3f3824cdbbe5ff412907572724f8fd5a/v2/MI_CID_748_F_Hitman/background.png", "full_background": "https://media.fortniteapi.io/images/cosmetics/3f3824cdbbe5ff412907572724f8fd5a/v2/MI_CID_748_F_Hitman/info.ru.png" }, { "displayAsset": "DAv2_CID_748_F_Hitman", "materialInstance": "MI_CID_748_F_Hitman_02", "url": "https://media.fortniteapi.io/images/displayAssets/v2/DAv2_CID_748_F_Hitman/MI_CID_748_F_Hitman_02.png", "flipbook": null, "background": "https://media.fortniteapi.io/images/cosmetics/3f3824cdbbe5ff412907572724f8fd5a/v2/MI_CID_748_F_Hitman_02/background.png", "full_background": "https://media.fortniteapi.io/images/cosmetics/3f3824cdbbe5ff412907572724f8fd5a/v2/MI_CID_748_F_Hitman_02/info.ru.png" } ], "shopHistory": [ "2020-04-18", "2020-04-19", "2020-05-21", "2020-06-20", "2020-07-20", "2020-08-22", "2020-09-23", "2020-10-23", "2020-11-30", "2020-12-01", "2020-12-28", "2021-01-28", "2021-03-05", "2021-04-05", "2021-05-07", "2021-06-14", "2021-06-15", "2021-07-16", "2021-07-17", "2021-08-16" ], "styles": [ { "name": "ОБЫЧНЫЙ", "channel": "Cosmetics.Variant.Channel.Material", "tag": "Cosmetics.Variant.Property.Mat1", "isDefault": true, "startUnlocked": true, "hideIfNotOwned": false, "image": "https://media.fortniteapi.io/images/styles/T-Soldier-HID-748-Athena-Commando-F-Hitman.png" }, { "name": "НУАР", "channel": "Cosmetics.Variant.Channel.Material", "tag": "Cosmetics.Variant.Property.Mat2", "isDefault": false, "startUnlocked": true, "hideIfNotOwned": false, "image": "https://media.fortniteapi.io/images/styles/T-Variant-748-Hitman-Noir.png" } ], "grants": [ { "id": "BID_518_HitmanCase", "type": { "id": "backpack", "name": "Украшение на спину" }, "name": "Поцелуй на прощание", "description": "Подарок на прощание.", "rarity": { "id": "Rare", "name": "РЕДКИЙ" }, "series": null, "images": { "icon": "https://media.fortniteapi.io/images/84fffdf71eb018a81610d8959263c90a/transparent.png", "featured": null, "background": "https://media.fortniteapi.io/images/cosmetics/84fffdf71eb018a81610d8959263c90a/v2/background.png", "full_background": "https://media.fortniteapi.io/images/cosmetics/84fffdf71eb018a81610d8959263c90a/v2/info.ru.png" } } ], "grantedBy": [] } }
В этом API ноги переломать можно, но нам главное получить список товаров с картинками и получить информацию об отдельном товаре с ценой. Все остальное нам не нужно, так что на весь этот ужас можно не обращать внимания. Похоже, что среди товаров есть наборы, есть обычная цена и цена со скидкой — много чего, что нам не потребуется.
Ключ API и URL-ы, на которые будем выполнять запросы, сохраним в отдельный файл config.js
:
export const API_KEY = 'your-api-key'; export const API_URL_LIST = 'https://fortniteapi.io/v2/items/list?lang=ru'; export const API_URL_ITEM = 'https://fortniteapi.io/v2/items/get?lang=ru';
Показываем список товаров
Для этого создадим компонент ShopList.js
, который будет отвечать за показ списка товаров и компонент ShopCard.js
, который будет отвечать за показ элемента списка.
import ShopList from './ShopList'; export default function Content() { return ( <main className="container"> <ShopList /> </main> ); }
import {useState, useEffect} from 'react'; import {API_KEY, API_URL_LIST} from '../config'; import Preloader from './Preloader'; import ShopCard from './ShopCard'; export default function ShopList(props) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { fetch(API_URL_LIST, { headers: { Authorization: API_KEY } }) .then(response => response.json()) .then(data => { data.items && setItems(data.items.slice(0, 24)); setLoading(false); }); }, []); return ( <div className="items"> {loading ? ( <Preloader /> ) : items.length ? ( items.map(item => ( <ShopCard key={item.id} {...item} /> )) ) : ( <p>Не удалось загрузить список</p> )} </div> ) }
export default function ShopCard(props) { const { id, name, price, images } = props; return ( <div id={"product-" + id} className="card"> <div className="card-image waves-effect waves-block waves-light"> <img className="activator" src={images.icon} alt="" /> </div> <div className="card-content"> <span className="card-title activator grey-text text-darken-4"> {name} </span> <p>Цена: {price} руб.</p> </div> <div className="card-action"> <button className="btn-small">Купить</button> <button className="btn-small right">Больше</button> </div> </div> ); }
Еще нам потребуется компонент Preloader.js
, который показывается в момент, когда выполняется запрос на получение списка товаров.
export default function Preloader() { return ( <div className="progress"> <div className="indeterminate"></div> </div> ); }
Добавление товара в корзину
Давайте создадим компонент иконки корзины, которая будет постоянно «висеть» в правом верхнем углу. Содержимое корзины будем хранить в состоянии компонента Content
. При клике на кнопку «Купить» нам нужно обновить содержимое корзины. Для этого нужно через пропсы передать функцию из Content
в ShopList
, а из ShopList
в ShopCard
. И уже в ShopCard
вызывать эту функцию по событию клика на кнопке «Купить».
export default function CartIcon(props) { return ( <div className="cart-icon"> <i className="material-icons">shopping_cart</i> {props.length ? <span>{props.length}</span> : null} </div> ); }
.cart-icon {
position: fixed;
z-index: 100;
right: 10px;
top: 10px;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
background-color: #EF5350;
}
import {useState} from 'react'; import CartIcon from './CartIcon'; import ShopList from './ShopList'; export default function Content() { const [cartItems, setCartItems] = useState([]); const appendToCart = (item, quantity = 1) => { // нужно проверить, нет ли уже такого товара в корзине const itemIndex = cartItems.findIndex(value => value.id === item.id); if (itemIndex < 0) { // такого товара еще нет const newItem = { ...item, quantity: quantity }; setCartItems([...cartItems, newItem]); } else { // такой товар уже есть const newItem = { ...cartItems[itemIndex], quantity: cartItems[itemIndex].quantity + quantity }; const newCart = cartItems.slice(); // копия массива cartItems newCart.splice(itemIndex, 1, newItem); setCartItems(newCart); } }; return ( <main className="container"> <CartIcon length={cartItems.length} /> <ShopList appendToCart={appendToCart} /> </main> ); }
Корзина в модальном окне
Содержимое корзины мы сохраняем в state
компонента Content
, но этого мало — нужна возможность просмотра этого содержимого. Давайте создадим для этого компоненты CartList
и CartItem
. А в компонент CartIcon
будем передавать через проп функцию, которая по клику будет показывать и скрывать модальное окно с корзиной.
import {useState} from 'react'; import CartIcon from './CartIcon'; import CartList from './CartList'; import ShopList from './ShopList'; export default function Content() { const [cartItems, setCartItems] = useState([]); const [showCart, setShowCart] = useState(false); // модальное окно /* .......... */ const toggleShow = () => setShowCart(!showCart); return ( <main className="container"> <CartIcon length={cartItems.length} toggleShow={toggleShow} /> <ShopList appendToCart={appendToCart} /> {showCart ? <CartList items={cartItems} toggleShow={toggleShow} /> : null} </main> ); }
export default function CartIcon(props) { return ( <div className="cart-icon" onClick={props.toggleShow}> <i className="material-icons">shopping_cart</i> {props.length ? <span>{props.length}</span> : null} </div> ); }
import CartItem from './CartItem'; export default function CartList(props) { // общая стоимость товаров в корзине const cost = props.items.reduce((sum, item) => sum + item.price * item.quantity, 0) return ( <div className="cart-modal"> <i className="material-icons cart-modal-close" onClick={props.toggleShow}> close </i> <h5 className="red-text text-lighten-1">Ваша корзина</h5> {props.items.length ? ( <table className="striped"> <thead> <tr> <th>Наименование</th> <th>Количество</th> <th>Цена</th> <th>Сумма</th> <th>Удалить</th> </tr> </thead> <tbody> {props.items.map(item => <CartItem key={item.id} {...item} />)} <tr> <th colSpan="3">Итого</th> <th>{cost}</th> <th>руб.</th> </tr> </tbody> </table> ) : ( <p>Ваша корзина пуста</p> )} </div> ); }
.cart-modal {
position: fixed;
z-index: 50;
min-width: 50%;
max-width: 100%;
max-height: 80vh;
overflow-y: auto;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #fff;
padding: 10px;
border: 3px solid #EF5350;
}
.cart-modal-close {
position: absolute;
top: 0;
right: 0;
color: #EF5350;
cursor: pointer;
font-weight: bold;
}
export default function CartItem(props) { return ( <tr> <td>{props.name}</td> <td>{props.quantity}</td> <td>{props.price}</td> <td>{props.price * props.quantity}</td> <td><i className="material-icons cart-item-delete">close</i></td> </tr> ); }
Удаление товара из корзины
Но мало показать содержимое корзины, нужна еще возможность удалить товар из корзины. Поэтому добавим еще одну функцию removeFromCart
в компонент Content
и будем ее передавать в компонент CartItem
.
import {useState} from 'react'; import CartIcon from './CartIcon'; import CartList from './CartList'; import ShopList from './ShopList'; export default function Content() { const [cartItems, setCartItems] = useState([]); const [showCart, setShowCart] = useState(false); // модальное окно /* .......... */ const removeFromCart = (id) => { const newCart = cartItems.filter(item => item.id !== id); setCartItems(newCart); } return ( <main className="container"> <CartIcon length={cartItems.length} toggleShow={toggleShow} /> <ShopList appendToCart={appendToCart} /> {showCart ? ( <CartList items={cartItems} toggleShow={toggleShow} removeFromCart={removeFromCart} /> ) : ( null )} </main> ); }
export default function CartItem(props) { return ( <tr> <td>{props.name}</td> <td>{props.quantity}</td> <td>{props.price}</td> <td>{props.price * props.quantity}</td> <td> <i className="material-icons cart-item-delete" onClick={() => props.removeFromCart(props.id)}> close </i> </td> </tr> ); }
Сообщение после добавления в корзину
После добавления товара в корзину хотелось бы показывать сообщение пользователю — мол, все прошло успешно. Давайте создадим компонент ShowAlert
— текст сообщения будем хранить в состоянии Content
, изначально текст равен null
, после добавления в корзину — «Товар добавлен в корзину». Через пару секунд это сообщение надо скрыть, поэтому в компонент через пропсы будем передавать функцию, которая через setTimeout
будет изменять текст в состоянии на null
.
import {useState} from 'react'; import CartIcon from './CartIcon'; import ShowAlert from './ShowAlert'; import CartList from './CartList'; import ShopList from './ShopList'; export default function Content() { const [cartItems, setCartItems] = useState([]); const [showCart, setShowCart] = useState(false); // модальное окно // для показа сообщения после добавления в корзину const [showAlert, setShowAlert] = useState(null); const appendToCart = (item, quantity = 1) => { /* .......... */ setShowAlert(item.name + ' добавлен в корзину'); }; /* .......... */ const hideAlert = () => setShowAlert(null); return ( <main className="container"> <CartIcon length={cartItems.length} toggleShow={toggleShow} /> {showAlert && <ShowAlert text={showAlert} hideAlert={hideAlert} />} <ShopList appendToCart={appendToCart} /> {showCart ? ( <CartList items={cartItems} toggleShow={toggleShow} removeFromCart={removeFromCart} /> ) : ( null )} </main> ); }
import {useEffect} from 'react'; export default function ShowAlert(props) { useEffect(() => { const timeoutId = setTimeout(props.hideAlert, 2000); return () => clearTimeout(timeoutId); }, [props.text]); return ( <div className="show-alert">{props.text}</div> ); }
.show-alert {
position: fixed;
z-index: 100;
right: 60px;
top: 10px;
background-color: #000;
color: #fff;
padding: 10px;
}
Исходные коды здесь, демо-сайт здесь.
Post Scriptum
Рефакторинг: используем контекст
Компонент Content
получился слишком большим — он содержит всю логику работы с корзиной покупателя. Кроме того, нам приходится пробрысывать через пропсы функции для добавления товара в корзину и для удаления товара из корзины. Причем пробрасывать далеко вниз, через промежуточные компоненты — это хлопотно и выглядит запутанно. Давайте создадим контекст корзины, где будем хранить ее содержимое и методы добавления и удаления товаров. И будем получать доступ к корзине в любом компоненте, используя хук useContext
(см. здесь). Так мы упростим код компонента Content
и избавимся от передачи функций вниз через пропсы.
Создаем файл CartContext.js
в директории src
:
import {createContext, useState} from 'react'; const CartContext = createContext(); const CartContextProvider = (props) => { const [arrItems, setArrItems] = useState([]); // все товары, которые сейчас в корзине const [showItems, setShowItems] = useState(false); // содержимое корзины сейчас показывается? const [showAlert, setShowAlert] = useState(null); // сообщение после добавления в корзину const append = (item, quantity = 1) => { // нужно проверить, нет ли уже такого товара в корзине const itemIndex = arrItems.findIndex(value => value.id === item.id); if (itemIndex < 0) { // такого товара еще нет const newItem = { ...item, quantity: quantity }; setArrItems([...arrItems, newItem]); } else { // такой товар уже есть const newItem = { ...arrItems[itemIndex], quantity: arrItems[itemIndex].quantity + quantity }; const newCart = arrItems.slice(); // копия массива arrItems newCart.splice(itemIndex, 1, newItem); setArrItems(newCart); } setShowAlert(item.name + ' добавлен в корзину'); }; const remove = (id) => { const newCart = arrItems.filter(item => item.id !== id); setArrItems(newCart); } const toggleShow = () => setShowItems(!showItems); const hideAlert = () => setShowAlert(null); // контекст, который будет доступен всем потомкам const value = { items: arrItems, append: append, remove: remove, showItems: showItems, toggleShow: toggleShow, showAlert: showAlert, hideAlert: hideAlert, }; return ( <CartContext.Provider value={value}> {props.children} </CartContext.Provider> ); } export {CartContext, CartContextProvider};
Теперь обеспечим передачу контекста вниз:
import React from 'react'; import Header from './components/Header'; import Footer from './components/Footer'; import Content from './components/Content'; import {CartContextProvider} from './CartContext'; export default function App() { return ( <CartContextProvider> <Header /> <Content /> <Footer /> </CartContextProvider> ); }
Из компонента Content
удалим все лишнее:
import {useContext} from 'react'; import CartIcon from './CartIcon'; import ShowAlert from './ShowAlert'; import CartList from './CartList'; import ShopList from './ShopList'; import {CartContext} from '../CartContext'; export default function Content() { const cart = useContext(CartContext); return ( <main className="container"> <CartIcon /> {cart.showAlert && <ShowAlert />} <ShopList /> {cart.showItems && <CartList />} </main> ); }
И доработаем остальные компоненты, чтобы работать с контекстом:
import {useContext} from 'react'; import {CartContext} from '../CartContext'; export default function CartIcon() { const cart = useContext(CartContext); return ( <div className="cart-icon" onClick={cart.toggleShow}> <i className="material-icons">shopping_cart</i> {cart.items.length ? <span>{cart.items.length}</span> : null} </div> ); }
import CartItem from './CartItem'; import {useContext} from 'react'; import {CartContext} from '../CartContext'; export default function CartList(props) { // контекст для доступа к корзине const cart = useContext(CartContext); // общая стоимость товаров в корзине const cost = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0); return ( <div className="cart-modal"> <i className="material-icons cart-modal-close" onClick={cart.toggleVisible}> close </i> <h5 className="red-text text-lighten-1">Ваша корзина</h5> {cart.items.length ? ( <table className="striped"> <thead> <tr> <th>Наименование</th> <th>Количество</th> <th>Цена</th> <th>Сумма</th> <th>Удалить</th> </tr> </thead> <tbody> {cart.items.map(item => <CartItem key={item.id} {...item} /> )} <tr> <th colSpan="3">Итого</th> <th>{cost}</th> <th>руб.</th> </tr> </tbody> </table> ) : ( <p>Ваша корзина пуста</p> )} </div> ); }
import {useContext} from 'react'; import {CartContext} from '../CartContext'; export default function CartItem(props) { const cart = useContext(CartContext); return ( <tr> <td>{props.name}</td> <td>{props.quantity}</td> <td>{props.price}</td> <td>{props.price * props.quantity}</td> <td> <i className="material-icons cart-item-delete" onClick={() => cart.remove(props.id)}> close </i> </td> </tr> ); }
import {useEffect, useContext} from 'react'; import {CartContext} from '../CartContext'; export default function ShowAlert() { const cart = useContext(CartContext); useEffect(() => { const timeoutId = setTimeout(cart.hideAlert, 2000); return () => clearTimeout(timeoutId); }, [cart.showAlert]); return ( <div className="show-alert">{cart.showAlert}</div> ); }
import {useState, useEffect} from 'react'; import {API_KEY, API_URL_LIST} from '../config'; import Preloader from './Preloader'; import ShopCard from './ShopCard'; export default function ShopList() { const [items, setItems] = useState([]); // товары магазина const [loading, setLoading] = useState(true); // идет загрузка? useEffect(() => { fetch(API_URL_LIST, { headers: { Authorization: API_KEY } }) .then(response => response.json()) .then(data => { data.items && setItems(data.items.slice(0, 24)); setLoading(false); }); }, []); return ( <div className="items"> {loading ? ( <Preloader /> ) : items.length ? ( items.map(item => ( <ShopCard key={item.id} {...item} /> )) ) : ( <p>Не удалось загрузить список товаров</p> )} </div> ) }
import {useContext} from 'react'; import {CartContext} from '../CartContext'; export default function ShopCard(props) { const { id, name, price, images } = props; const item = {id: id, name: name, price: price}; const cart = useContext(CartContext); return ( <div id={"product-" + id} className="card"> <div className="card-image waves-effect waves-block waves-light"> <img className="activator" src={images.icon} alt="" /> </div> <div className="card-content"> <span className="card-title activator grey-text text-darken-4"> {name} </span> <p>Цена: {price} руб.</p> </div> <div className="card-action"> <button className="btn-small" onClick={() => cart.append(item, 1)} > Купить </button> <button className="btn-small right">Больше</button> </div> </div> ); }
Рефакторинг: используем reducer
Теперь все для работы с корзиной у нас в контексте. Давайте еще весь код управления состоянием корзины вынесем в отдельную функцию CartReducer
. Это будет «чистая» функция без побочных эффектов, которая никак не зависит от React (см. здесь).
Для этого редактируем src/CartContext.js
и создаем новый файл src/CartReducer.js
:
import {createContext, useReducer} from 'react'; import CartReducer from './CartReducer'; const CartContext = createContext(); const initState = { items: [], showItems: false, showAlert: null } const CartContextProvider = (props) => { const [value, dispatch] = useReducer(CartReducer, initState); value.append = (item, quantity = 1) => { // добавить товар в корзину dispatch({type: 'APPEND_ITEM', payload: {item: item, quantity: quantity}}); } value.remove = (id) => { // удалить товар из корзины dispatch({type: 'REMOVE_ITEM', payload: {id: id}}); } value.toggleShow = () => { // показать/скрыть корзину dispatch({type: 'TOGGLE_SHOW'}); } value.hideAlert = () => { // скрыть сообщение о добавлении в корзину dispatch({type: 'HIDE_ALERT'}); } return ( <CartContext.Provider value={value}> {props.children} </CartContext.Provider> ); } export {CartContext, CartContextProvider};
export default function CartReducer(state, {type, payload}) { switch(type) { case 'APPEND_ITEM': // добавить товар в корзину let newCart = null; // нужно проверить, нет ли уже такого товара в корзине const itemIndex = state.items.findIndex(value => value.id === payload.item.id); if (itemIndex < 0) { // такого товара еще нет const newItem = { ...payload.item, quantity: payload.quantity }; newCart = [...state.items, newItem]; } else { // такой товар уже есть const newItem = { ...state.items[itemIndex], quantity: state.items[itemIndex].quantity + payload.quantity }; newCart = [...state.items]; // копия массива state.items newCart.splice(itemIndex, 1, newItem); } return { ...state, items: newCart, showAlert: payload.item.name + ' добавлен в корзину' } case 'REMOVE_ITEM': // удалить товар из корзины return { ...state, items: state.items.filter(item => item.id !== payload.id) }; case 'TOGGLE_SHOW': // показать/скрыть корзину return { ...state, showItems: !state.showItems }; case 'HIDE_ALERT': // скрыть сообщение о добавлении в корзину return { ...state, showAlert: null }; default: return state; } }
- Магазин на JavaScript, часть 19 из 19. Редактирование характеристик и рефакторинг приложения
- Магазин на JavaScript, часть 18 из 19. Панель управления: редактирование категорий и брендов
- Магазин на JavaScript, часть 17 из 19. Панель управления: список заказов, категорий и брендов
- Магазин на JavaScript, часть 15 из 19. Работа с заказами на сервере, оформление заказа
- Магазин на JavaScript, часть 14 из 19. Кнопка «Назад», страница товара, корзина покупателя
- Магазин на JavaScript, часть 13 из 19. Хранилище каталога, компонент витрины, кнопка «Назад»
- Магазин на JavaScript, часть12 из 19. Запросы на сервер, состояние приложения, Signup и Login
Поиск: JavaScript • React.js • Web-разработка • Frontend • Интернет магазин • Каталог товаров • Компонент • Корзина • Практика