Магазин на JavaScript, часть 14 из 19. Кнопка «Назад», страница товара, корзина покупателя
06.01.2022
Теги: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • БазаДанных • ИнтернетМагазин • КаталогТоваров • Корзина • Фреймворк
Кнопки «Назад» и «Вперед» браузера
Второй способ
У первого способа есть существенный недостаток — нельзя поделиться ссылкой. То есть, пользователь зашел на наш сайт, выбрал категорию и бренд, перешел на вторую страницу, увидел что-то интересное — и решил поделиться ссылкой. Но эта ссылка будет иметь вид 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
- Магазин на JavaScript, часть 19 из 19. Редактирование характеристик и рефакторинг приложения
- Магазин на JavaScript, часть 18 из 19. Панель управления: редактирование категорий и брендов
- Магазин на JavaScript, часть 17 из 19. Панель управления: список заказов, категорий и брендов
- Магазин на JavaScript, часть 15 из 19. Работа с заказами на сервере, оформление заказа
- Магазин на JavaScript, часть 13 из 19. Хранилище каталога, компонент витрины, кнопка «Назад»
- Магазин на JavaScript, часть12 из 19. Запросы на сервер, состояние приложения, Signup и Login
- Магазин на JavaScript, часть 11 из 19. Компоненты Signup и Login, компоненты Shop и Basket
Поиск: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • База данных • Интернет магазин • Каталог товаров • Корзина • Фреймворк