React.js. Витрина интернет-магазина и корзина

20.08.2021

Теги: FrontendJavaScriptReact.jsWeb-разработкаИнтернетМагазинКаталогТоваровКомпонентКорзинаПрактика

Небольшое приложение — витрина интернет-магазина + корзина покупателя, не имеет практической ценности, сделано исключительно с целью изучения 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 • React.js • Web-разработка • Frontend • Интернет магазин • Каталог товаров • Компонент • Корзина • Практика

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.