Магазин на JavaScript, часть 14 из 19. Кнопка «Назад», страница товара, корзина покупателя

06.01.2022

Теги: BackendExpress.jsFrontendJavaScriptNode.jsORMReact.jsWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровКорзинаФреймворк

Кнопки «Назад» и «Вперед» браузера

Второй способ

У первого способа есть существенный недостаток — нельзя поделиться ссылкой. То есть, пользователь зашел на наш сайт, выбрал категорию и бренд, перешел на вторую страницу, увидел что-то интересное — и решил поделиться ссылкой. Но эта ссылка будет иметь вид http://server.com — нет выбранной категории, выбранного бренда и номера страницы. Нам надо добавлять в адресную строку браузера GET-параметры, чтобы можно было восстановить состояние.

import { ListGroup } from 'react-bootstrap'
import { useContext } from 'react'
import { AppContext } from './AppContext.js'
import { observer } from 'mobx-react-lite'
import { useNavigate, createSearchParams } from 'react-router-dom'

const CategoryBar = observer(() => {
    const { catalog } = useContext(AppContext)
    const navigate = useNavigate()

    const handleClick = (id) => {
        if (id === catalog.category) {
            catalog.category = null
        } else {
            catalog.category = id
        }
        // при каждом клике добавляем в историю браузера новый элемент
        const params = {}
        if (catalog.category) params.category = catalog.category
        if (catalog.brand) params.brand = catalog.brand
        if (catalog.page > 1) params.page = catalog.page
        navigate({
            pathname: '/',
            search: '?' + createSearchParams(params),
        })
    }

    return (
        <ListGroup>
            {catalog.categories.map(item =>
                <ListGroup.Item
                    key={item.id}
                    active={item.id === catalog.category}
                    onClick={() => handleClick(item.id)}
                    style={{cursor: 'pointer'}}
                >
                    {item.name}
                </ListGroup.Item>
            )}
        </ListGroup>
    )
})

export default CategoryBar
import { ListGroup } from 'react-bootstrap'
import { useContext } from 'react'
import { AppContext } from './AppContext.js'
import { observer } from 'mobx-react-lite'
import { useNavigate, createSearchParams } from 'react-router-dom'

const BrandBar = observer(() => {
    const { catalog } = useContext(AppContext)
    const navigate = useNavigate()

    const handleClick = (id) => {
        if (id === catalog.brand) {
            catalog.brand = null
        } else {
            catalog.brand = id
        }
        // при каждом клике добавляем в историю браузера новый элемент
        const params = {}
        if (catalog.category) params.category = catalog.category
        if (catalog.brand) params.brand = catalog.brand
        if (catalog.page > 1) params.page = catalog.page
        navigate({
            pathname: '/',
            search: '?' + createSearchParams(params),
        })
    }

    return (
        <ListGroup horizontal>
            {catalog.brands.map(item =>
                <ListGroup.Item
                    key={item.id}
                    active={item.id === catalog.brand}
                    onClick={() => handleClick(item.id)}
                    style={{cursor: 'pointer'}}
                >
                    {item.name}
                </ListGroup.Item>
            )}
        </ListGroup>
    )
})

export default BrandBar
import { Container, Row, Col, Spinner } from 'react-bootstrap'
import CategoryBar from '../components/CategoryBar.js'
import BrandBar from '../components/BrandBar.js'
import ProductList from '../components/ProductList.js'
import { useContext, useEffect, useState } from 'react'
import { AppContext } from '../components/AppContext.js'
import { fetchCategories, fetchBrands, fetchAllProducts } from '../http/catalogAPI.js'
import { observer } from 'mobx-react-lite'
import { useLocation, useSearchParams } from 'react-router-dom'

const getSearchParams = (searchParams) => {
    let category = searchParams.get('category')
    if (category && /[1-9][0-9]*/.test(category)) {
        category = parseInt(category)
    }
    let brand = searchParams.get('brand')
    if (brand && /[1-9][0-9]*/.test(brand)) {
        brand = parseInt(brand)
    }
    let page = searchParams.get('page')
    if (page && /[1-9][0-9]*/.test(page)) {
        page = parseInt(page)
    }
    return {category, brand, page}
}

const Shop = observer(() => {
    const { catalog } = useContext(AppContext)

    const [categoriesFetching, setCategoriesFetching] = useState(true)
    const [brandsFetching, setBrandsFetching] = useState(true)
    const [productsFetching, setProductsFetching] = useState(true)

    const location = useLocation()
    const [searchParams] = useSearchParams()

    useEffect(() => {
        fetchCategories()
            .then(data => catalog.categories = data)
            .finally(() => setCategoriesFetching(false))

        fetchBrands()
            .then(data => catalog.brands = data)
            .finally(() => setBrandsFetching(false))

        const {category, brand, page} = getSearchParams(searchParams)
        catalog.category = category
        catalog.brand = brand
        catalog.page = page ?? 1

        fetchAllProducts(catalog.category, catalog.brand, catalog.page, catalog.limit)
            .then(data => {
                catalog.products = data.rows
                catalog.count = data.count
            })
            .finally(() => setProductsFetching(false))
        // eslint-disable-next-line
    }, [])

    useEffect(() => {
        const {category, brand, page} = getSearchParams(searchParams)

        if (category || brand || page) {
            if (category !== catalog.category) catalog.category = category
            if (brand !== catalog.brand) catalog.brand = brand
            if (page !== catalog.page) catalog.page = page ?? 1
        } else  {
            catalog.category = null
            catalog.brand = null
            catalog.page = 1
        }
        // eslint-disable-next-line
    }, [location.search])

    useEffect(() => {
        setProductsFetching(true)
        setTimeout(() => {
            fetchAllProducts(catalog.category, catalog.brand, catalog.page, catalog.limit)
                .then(data => {
                    catalog.products = data.rows
                    catalog.count = data.count
                })
                .finally(() => setProductsFetching(false))
        }, 1000)
        // eslint-disable-next-line
    }, [catalog.category, catalog.brand, catalog.page])

    return (
        <Container>
            <Row className="mt-2">
                <Col md={3} className="mb-3">
                    {categoriesFetching ? (
                        <Spinner animation="border" />
                    ) : (
                        <CategoryBar />
                    )}
                </Col>
                <Col md={9}>
                    <div>
                        {brandsFetching ? (
                            <Spinner animation="border" />
                        ) : (
                            <BrandBar />
                        )}
                    </div>
                    <div>
                        {productsFetching ? (
                            <Spinner animation="border" />
                        ) : (
                            <ProductList />
                        )}
                    </div>
                </Col>
            </Row>
        </Container>
    )
})

export default Shop

При начальной загрузке каталога мы проверяем наличие GET-параметров и если они есть — выполняем запрос на сервер с учетом выбранной категории, бренда и страницы.

Страница товара

Как-то совсем упустил из вида эту страницу. Нужно добавить еще один маршрут в компонент AppRouter, сверстать страницу pages/Product.js, добавить обработчик клика в компонент ProductItem (для перехода на страницу товара).

import { Routes, Route } from 'react-router-dom'
import Shop from '../pages/Shop.js'
import Login from '../pages/Login.js'
import Signup from '../pages/Signup.js'
import Basket from '../pages/Basket.js'
import Product from '../pages/Product.js'
import Delivery from '../pages/Delivery.js'
import Contacts from '../pages/Contacts.js'
import NotFound from '../pages/NotFound.js'
import User from '../pages/User.js'
import Admin from '../pages/Admin.js'
import { AppContext } from './AppContext.js'
import { useContext } from 'react'
import { observer } from 'mobx-react-lite'

const publicRoutes = [
    {path: '/', Component: Shop},
    {path: '/login', Component: Login},
    {path: '/signup', Component: Signup},
    {path: '/product/:id', Component: Product},
    {path: '/basket', Component: Basket},
    {path: '/delivery', Component: Delivery},
    {path: '/contacts', Component: Contacts},
    {path: '*', Component: NotFound},
]

const authRoutes = [
    {path: '/user', Component: User},
]

const adminRoutes = [
    {path: '/admin', Component: Admin},
]

const AppRouter = observer(() => {
    const { user } = useContext(AppContext)
    return (
        <Routes>
            {publicRoutes.map(({path, Component}) =>
                <Route key={path} path={path} element={<Component />} />
            )}
            {user.isAuth && authRoutes.map(({path, Component}) =>
                <Route key={path} path={path} element={<Component />} />
            )}
            {user.isAdmin && adminRoutes.map(({path, Component}) =>
                <Route key={path} path={path} element={<Component />} />
            )}
        </Routes>
    )
})

export default AppRouter
import { Card, Col } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'

const ProductItem = ({data}) => {
    const navigate = useNavigate()
    return (
        <Col xl={3} lg={4} sm={6} className="mt-3" onClick={() => navigate(`/product/${data.id}`)}>
            <Card style={{width: 200, cursor: 'pointer'}}>
                {data.image ? (
                    <Card.Img variant="top" src={process.env.REACT_APP_IMG_URL + data.image} />
                ) : (
                    <Card.Img variant="top" src="http://via.placeholder.com/200" />
                )}
                <Card.Body style={{height: 100, overflow: 'hidden'}}>
                    <p>Бренд: {data.brand.name}</p>
                    <strong>{data.name}</strong>
                </Card.Body>
            </Card>
        </Col>
    )
}

export default ProductItem
import { Container, Row, Col, Button, Image, Spinner } from 'react-bootstrap'
import { useEffect, useState } from 'react'
import { fetchOneProduct, fetchProdRating } from '../http/catalogAPI.js'
import { useParams } from 'react-router-dom'

const Product = () => {
    const { id } = useParams()
    const [product, setProduct] = useState(null)
    const [rating, setRating] = useState(null)

    useEffect(() => {
        fetchOneProduct(id).then(data => setProduct(data))
        fetchProdRating(id).then(data => setRating(data))
    }, [id])

    if (!product) {
        return <Spinner animation="border" />
    }

    return (
        <Container>
            <Row className="mt-3 mb-3">
                <Col lg={4}>
                    {product.image ? (
                        <Image width={300} height={300} src={process.env.REACT_APP_IMG_URL + product.image} />
                    ) : (
                        <Image width={300} height={300} src="http://via.placeholder.com/300" />
                    )}
                </Col>
                <Col lg={8}>
                    <h1>{product.name}</h1>
                    <h3>{product.price}.00 руб.</h3>
                    <p>Бренд: {product.brand.name}</p>
                    <p>Категория: {product.category.name}</p>
                    <div>
                        {rating ? (
                            <p>Рейтинг: {rating.rating}, голосов {rating.votes}</p>
                        ) : (
                            <Spinner animation="border" />
                        )}
                    </div>
                    <Button>Добавить в корзину</Button>
                </Col>
            </Row>
            {!!product.props.length &&
                <Row>
                    <Col>
                        <h3>Характеристики</h3>
                        <Table bordered hover size="sm">
                            <tbody>
                                {product.props.map(item => 
                                    <tr key={item.id}>
                                        <td>{item.name}</td>
                                        <td>{item.value}</td>
                                    </tr>
                                )}
                            </tbody>
                        </Table>
                    </Col>
                </Row>
            }
        </Container>
    )
}

export default Product

Пришлось еще доработать модель models/Product.js на сервере, чтобы получать категорию и бренд:

class Product {
    /* .......... */
    async getOne(id) {
        const product = await ProductMapping.findByPk(id, {
            include: [
                {model: ProductPropMapping, as: 'props'},
                {model: BrandMapping, as: 'brand'},
                {model: CategoryMapping, as: 'category'},
            ]
        })
        if (!product) {
            throw new Error('Товар не найден в БД')
        }
        return product
    }
    /* .......... */
}

Для получения рейтинга товара нужно добавить еще одну функцию в http/catalogAPI.js:

/* .......... */
export const fetchProdRating = async (id) => {
    const { data } = await guestInstance.get(`rating/product/${id}`)
    return data
}

Корзина покупателя

Первым делом нам нужно при http-запросах к серверу отправлять cookie с идентификатором корзины, так что редактируем файл http/index.js:

import axios from 'axios'

const guestInstance = axios.create({
    baseURL: process.env.REACT_APP_API_URL,
    withCredentials: true // отправлять cookie
})

const authInstance = axios.create({
    baseURL: process.env.REACT_APP_API_URL,
    withCredentials: true // отправлять cookie
})
/* .......... */

Но после этого приложение вообще перестает работать, но в панели разработчика браузера есть сообщение, которое объясняет — почему все сломалось:

Access to XMLHttpRequest at 'http://localhost:7000/api/category/getall' from origin 'http://localhost:3000' has
been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the
wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the
XMLHttpRequest is controlled by the withCredentials attribute.

Так что на сервере добавляем в файл server/index.js настройки CORS (Cross-Origin Resource Sharing), чтобы разрешить cookie от клиента:

const app = express()
// Cross-Origin Resource Sharing
app.use(cors({origin: 'http://localhost:3000', credentials: true}))

Создаем файл http/basketAPI.js для выполнения HTTP-запросов при работе с корзиной покупателя:

import { guestInstance } from './index.js'

export const fetchBasket = async () => {
    const { data } = await guestInstance.get('basket/getone')
    return data
}

export const append = async (id) => {
    const { data } = await guestInstance.put(`basket/product/${id}/append/1`)
    return data
}

export const increment = async (id) => {
    const { data } = await guestInstance.put(`basket/product/${id}/increment/1`)
    return data
}

export const decrement = async (id) => {
    const { data } = await guestInstance.put(`basket/product/${id}/decrement/1`)
    return data
}

export const remove = async (id) => {
    const { data } = await guestInstance.put(`basket/product/${id}/remove`)
    return data
}

export const clear = async () => {
    const { data } = await guestInstance.put(`basket/clear`)
    return data
}

Создаем хранилище корзины store/BasketStore.js по аналогии с хранилищем каталога и пользователя:

import { makeAutoObservable } from 'mobx'

class BasketStore {
    _products = []

    constructor() {
        makeAutoObservable(this)
    }

    get products() {
        return this._products
    }

    get count() { // всего позиций в корзине
        return this._products.length
    }

    get sum() { // стоимость всех товаров корзины
        return this._products.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }

    set products(products) {
        this._products = products
    }
}

export default BasketStore

И добавляем хранилище корзины в контекст, чтобы везде иметь к нему доступ через хук useContext:

import React from 'react'
import CatalogStore from '../store/CatalogStore.js'
import UserStore from '../store/UserStore.js'
import BasketStore from '../store/BasketStore.js'

const AppContext = React.createContext()

// контекст, который будем передавать
const context = {
    user: new UserStore(),
    catalog: new CatalogStore(),
    basket: new BasketStore(),
}

const AppContextProvider = (props) => {
    return (
        <AppContext.Provider value={context}>
            {props.children}
        </AppContext.Provider>
    );
}

export {AppContext, AppContextProvider}

Когда пользователь только зашел на сайт — надо запросить с сервера его корзину, если она существует. И показывать в главном меню ссылку на корзину + количество позиций в ней. Для этого создадим HOC-компонент FetchBasket.js и обернем в него ссылку на корзину.

import { AppContext } from './AppContext.js'
import { fetchBasket } from '../http/basketAPI.js'
import { useContext, useEffect, useState } from 'react'
import { Spinner } from 'react-bootstrap'

const FetchBasket = (props) => {
    const { basket } = useContext(AppContext)
    const [fetching, setFetching] = useState(true)
    
    useEffect(() => {
        fetchBasket()
            .then(
                data => basket.products = data.products
            )
            .finally(
                () => setFetching(false)
            )
    }, [])

    if (fetching) {
        return <Spinner animation="border" variant="light" />
    }

    return props.children
}

export default FetchBasket
import { Container, Navbar, Nav } from 'react-bootstrap'
import { NavLink } from 'react-router-dom'
import { AppContext } from './AppContext.js'
import { useContext } from 'react'
import { observer } from 'mobx-react-lite'
import CheckAuth from './CheckAuth.js'
import FetchBasket from './FetchBasket.js'

const NavBar = observer(() => {
    const { user, basket } = useContext(AppContext)
    return (
        <Navbar bg="dark" variant="dark">
            <Container>
                <NavLink to="/" className="navbar-brand">Магазин</NavLink>
                <Nav className="ml-auto">
                    <NavLink to="/delivery" className="nav-link">Доставка</NavLink>
                    <NavLink to="/contacts" className="nav-link">Контакты</NavLink>
                    <CheckAuth>
                        {user.isAuth ? (
                            <NavLink to="/user" className="nav-link">Личный кабинет</NavLink>
                        ) : (
                            <>
                                <NavLink to="/login" className="nav-link">Войти</NavLink>
                                <NavLink to="/signup" className="nav-link">Регистрация</NavLink>
                            </>
                        )}
                        {user.isAdmin && (
                            <NavLink to="/admin" className="nav-link">Панель управления</NavLink>
                        )}
                    </CheckAuth>
                    <FetchBasket>
                        <NavLink to="/basket" className="nav-link">
                            Корзина
                            {!!basket.count && <span>({basket.count})</span>}
                        </NavLink>
                    </FetchBasket>
                </Nav>
            </Container>
        </Navbar>
    )
})

export default NavBar

На странице товара добавим обработчик клика по кнопке «Добавить в корзину»:

import { Container, Row, Col, Button, Image, Spinner } from 'react-bootstrap'
import { useEffect, useState, useContext } from 'react'
import { fetchOneProduct, fetchProdRating } from '../http/catalogAPI.js'
import { useParams } from 'react-router-dom'
import { append } from '../http/basketAPI.js'
import { AppContext } from '../components/AppContext.js'

const Product = () => {
    const { id } = useParams()
    const { basket } = useContext(AppContext)
    const [product, setProduct] = useState(null)
    const [rating, setRating] = useState(null)

    useEffect(() => {
        fetchOneProduct(id).then(data => setProduct(data))
        fetchProdRating(id).then(data => setRating(data))
    }, [id])

    const handleClick = (productId) => {
        append(productId).then(data => {
            basket.products = data.products
        })
    }

    if (!product) {
        return <Spinner animation="border" />
    }

    return (
        <Container>
            <Row className="mt-3 mb-3">
                <Col lg={4}>
                    {product.image ? (
                        <Image width={300} height={300} src={'/' + product.image} />
                    ) : (
                        <Image width={300} height={300} src="http://via.placeholder.com/300" />
                    )}
                </Col>
                <Col lg={8}>
                    <h1>{product.name}</h1>
                    <h3>{product.price}.00 руб.</h3>
                    <p>Бренд: {product.brand.name}</p>
                    <p>Категория: {product.category.name}</p>
                    <div>
                        {rating ? (
                            <p>Рейтинг: {rating.rating}, голосов {rating.votes}</p>
                        ) : (
                            <Spinner animation="border" />
                        )}
                    </div>
                    <Button onClick={() => handleClick(product.id)}>Добавить в корзину</Button>
                </Col>
            </Row>
            {!!product.props.length &&
                <Row>
                    <Col>
                        <h3>Характеристики</h3>
                            <Table bordered hover size="sm">
                                <tbody>
                                    {product.props.map(item => 
                                        <tr key={item.id}>
                                            <td>{item.name}</td>
                                            <td>{item.value}</td>
                                        </tr>
                                    )}
                                </tbody>
                            </Table>
                    </Col>
                </Row>
            }
        </Container>
    )
}

export default Product

Осталось только доработать компонент pages/Basket.js, чтобы работать с хранилищем корзины (а не просто с массивом):

import { useContext } from 'react'
import { AppContext } from './AppContext.js'
import { Table } from 'react-bootstrap'
import BasketItem from './BasketItem.js'
import { observer } from 'mobx-react-lite'

const BasketList = observer(() => {
    const { basket } = useContext(AppContext)
    return (
        <>
            {basket.count ? (
                <Table bordered hover size="sm" className="mt-3">
                    <thead>
                        <tr>
                            <th>Наименование</th>
                            <th>Количество</th>
                            <th>Цена</th>
                            <th>Сумма</th>
                            <th>Удалить</th>
                        </tr>
                    </thead>
                    <tbody>
                        {basket.products.map(item => <BasketItem key={item.id} {...item} />)}
                        <tr>
                            <th colSpan="3">Итого</th>
                            <th>{basket.sum}</th>
                            <th>руб.</th>
                        </tr>
                    </tbody>
                </Table>
            ) : (
                <p>Ваша корзина пуста</p>
            )}
        </>
    )
})

export default BasketList

Почти все хорошо, только при перезагрузке страницы корзины мы на секунду видим сообщение «Ваша корзина пуста». Это происходит потому, что изначально при создании экземпляра BasketStore масссив _products пустой. Потом HOC-компонент FetchBasket получает с сервера содержимое корзины, массив _products изменяется, а поскольку это observable-значение, а BasketList обернут в observer — это вызывает новый рендер.

import { useContext, useState, useEffect } from 'react'
import { AppContext } from './AppContext.js'
import { fetchBasket } from '../http/basketAPI.js'
import { Table, Spinner } from 'react-bootstrap'
import BasketItem from './BasketItem.js'
import { observer } from 'mobx-react-lite'

const BasketList = observer(() => {
    const { basket } = useContext(AppContext)
    const [fetching, setFetching] = useState(true)

    useEffect(() => {
        fetchBasket()
            .then(
                data => basket.products = data.products
            )
            .finally(
                () => setFetching(false)
            )
    }, [])

    if (fetching) {
        return <Spinner animation="border" />
    }

    return (
        <>
            {basket.count ? (
                <Table bordered hover size="sm" className="mt-3">
                    <thead>
                        <tr>
                            <th>Наименование</th>
                            <th>Количество</th>
                            <th>Цена</th>
                            <th>Сумма</th>
                            <th>Удалить</th>
                        </tr>
                    </thead>
                    <tbody>
                        {basket.products.map(item => <BasketItem key={item.id} {...item} />)}
                        <tr>
                            <th colSpan="3">Итого</th>
                            <th>{basket.sum}</th>
                            <th>руб.</th>
                        </tr>
                    </tbody>
                </Table>
            ) : (
                <p>Ваша корзина пуста</p>
            )}
        </>
    )
})

export default BasketList

Поиск: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • База данных • Интернет магазин • Каталог товаров • Корзина • Фреймворк

Каталог оборудования
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.