User orders and shopping cart

Using the React Reducer to handle user orders and a shopping cart.

Project Goals

In this ninth stage of your MERN project, you will:

  • Use the React Reducer to handle user orders and a shopping cart.

Introduction

In React, state management was often accomplished by lifting state up to a common parent component, like in the card component.

React Reducer is a built-in feature that allows for a more structured and centralized approach to managing state within components.

A reducer is a pure function that receives two arguments: the current state and an action object. The state represents the current state of the application, while the action describes the type of state change to be performed. Inside the reducer, you can use a switch statement to determine the appropriate action type and update the state accordingly.

Actions are plain JavaScript objects that describe an intention to change the state. They typically consist of a type property, which indicates the action type, and optionally, a payload property that carries additional data. Action types are typically defined as constants to avoid typographical errors.

React Reducer is typically used in conjunction with the useReducer hook, another built-in feature of React. The useReducer hook takes a reducer function and an initial state as arguments and returns the current state and a dispatch function. The dispatch function is used to send actions to the reducer, triggering state updates.

Installing the reducer package

Your first step is to install the package in your /frontend folder:

npm i react-redux @reduxjs/toolkit

Adding the shopping cart actions

Follow these steps:

  1. In your frontend/src/ sub-folder, create a new folder named /store
  2. In this frontend/src/store sub-folder, create a new folder named /cart.
  3. In the /cart sub-folder, create a new file named actionTypes.jsx and paste the code below into it:
    
    export const ADD_TO_CART = "ADD_TO_CART";
    export const REMOVE_ITEM = "REMOVE_ITEM";
    export const EMPTY_CART = "EMPTY_CART";
  4. In the same /cart sub-folder, create a new file named cartActions.jsx and paste the code below into it:
    
    import * as actionTypes from "./actionTypes.jsx";
    
    //receives the object
    export const addToCart = (item) => {
      return {
        type: actionTypes.ADD_TO_CART,
        item: item,
      };
    };
    
    //receives the object id 
    export const removeFromCart = (id) => {
      return {
        type: actionTypes.REMOVE_ITEM,
        id: id,
      };
    };
    
    export const emptyCart = () => {
      return {
        type: actionTypes.EMPTY_CART,
      };
    };
  5. Finally, create a third file named cartReducer.jsx and paste this code into it:
    
    import { ADD_TO_CART, REMOVE_ITEM, EMPTY_CART } from "./actionTypes.jsx";
    
    const initState = {
      addedItems: [],
      total: 0,
    };
    const cartReducer = (state = initState, action) => {
    
      if (action.type === ADD_TO_CART) {
        console.log("acion", action);
        let addedItem = action.item.product;
        let itemAmount = action.item.amount;
        
        //check if the action id exists in the addedItems
        let existed_item = state.addedItems.find(
          (item) => addedItem._id === item._id
        );
        
        if (existed_item) {
          let updatedItem = { ...existed_item };
          updatedItem.quantity =
            parseInt(updatedItem.quantity) + parseInt(itemAmount);
    
          // Create a new array for the modified addedItems
          let updatedAddedItems = state.addedItems.map((item) =>
            item._id === existed_item._id ? updatedItem : item
          );
          
          return {
            ...state,
            addedItems: updatedAddedItems,
            total: state.total + addedItem.price * itemAmount,
          };
        } else {
          addedItem.quantity = parseInt(itemAmount);
          
          //calculating the total
          let newTotal = state.total + addedItem.price * itemAmount;
          
          return {
            ...state,
            addedItems: [...state.addedItems, addedItem],
            total: newTotal,
          };
        }
      } else if (action.type === REMOVE_ITEM) {
        
        let existed_item = state.addedItems.find((item) => action.id === item._id);
        if (existed_item.quantity > 1) {
          let updatedItem = { ...existed_item };
          updatedItem.quantity -= 1;
    
          // Create a new array for the modified addedItems
          let updatedAddedItems = state.addedItems.map((item) =>
            item._id === existed_item._id ? updatedItem : item
          );
          
          return {
            ...state,
            addedItems: updatedAddedItems,
            total: state.total - existed_item.price,
          };
        } else {
          let existed_item = state.addedItems.find(
            (item) => action.id === item._id
          );
          let new_items = state.addedItems.filter((item) => action.id !== item._id);
    
          //calculating the total
          let newTotal = state.total - existed_item.price * existed_item.quantity;
    
          return {
            ...state,
            addedItems: new_items,
            total: newTotal,
          };
        }
      } else if (action.type === EMPTY_CART) {
        return {
          ...state,
          addedItems: [],
          total: 0,
        };
      }
        return state;
      }
    
    export default cartReducer;

Creating the CartPage.jsx page

After creating the cart reducer, you next need to create the CartPage where you can display the cart data.

Into your frontend/src/pages sub-folder, download the following file:

CartPage.jsx

Creating an OrderPage.jsx file

Into your frontend/src/pages sub-folder, download the following file:

OrdersPage.jsx

Adding the ecommerce images

Follow the steps below:

  1. Download the following three images:
    emptycart.png
    logo_2.png
    not_found.png
  2. In your frontend/src folder, create a new sub-folder named /img.
  3. Copy the above three images into this new sub-folder.

Creating the orders.js Model

Now it’s time to work on the backend layer of your app. Begin by creating an order.js file in your backend/Models sub-folder. Paste the code below into this file:


import mongoose from "mongoose";

const orderSchema = new mongoose.Schema({
  userID: { type: String, required: true },
  firstName: { type: String, required: true },
  lastName: { type: String, required: true },
  address: { type: String, required: true },
  city: { type: String, required: true },
  country: { type: String, required: true },
  zipCode: { type: String, required: true },
  totalAmount: { type: String, required: true },
  items: { type: String, required: true },
  createdDate: { type: Date, required: true },
});

const Order = mongoose.model("Order", orderSchema);

export default Order;

Creating the orders.js Route

In your backend/Routes sub-folder, create an order.js file and paste the code below into this file:


import express from "express";
import mongoose from "mongoose";
import Order from "../Models/products.js";
const router = express.Router();

//Post new order
//Create API
router.post("/create", async (req, res) => {
  console.log(req.body);
  const newOrder = new Order({
    userID: req.body.data.userID,
    firstName: req.body.data.firstName,
    lastName: req.body.data.lastName,
    address: req.body.data.address,
    city: req.body.data.city,
    country: req.body.data.country,
    zipCode: req.body.data.zipCode,
    totalAmount: req.body.data.totalAmount,
    items: JSON.stringify(req.body.data.items),
    createdDate: req.body.data.createdDate,
  });

  await Order.create(newOrder);
  res.send("Order saved to the database!");
});

router.get("/:userId", async (req, res) => {
  const userID = req.params.userId;
  const orderList = await Order.find({ userID: userID });
  res.send(orderList);
});

export default router;

Updating the ProductCard.jsx file

In your frontend/src/components/ sub-folder, modify the ProductCard.jsx file as follows:


import React, { useState, useEffect, useRef } from "react";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Card from "@mui/material/Card";
import CardHeader from "@mui/material/CardHeader";
import CardMedia from "@mui/material/CardMedia";
import CardContent from "@mui/material/CardContent";
import CardActions from "@mui/material/CardActions";
import Rating from "@mui/material/Rating";
import Stack from "@mui/material/Stack";
import { Button } from "@mui/material";
import { useNavigate } from "react-router";
import AddShoppingCartIcon from "@mui/icons-material/AddShoppingCart";

import { addToCart, removeFromCart } from "../store/cart/cartActions";
import { useDispatch } from "react-redux";
import axios from "axios";

const baseURL = import.meta.env.VITE_BASE_URL;
axios.defaults.baseURL = baseURL;

const ProductCard = (props) => {
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const [product, setProduct] = useState(props.product);
  const [token, setToken] = useState();
  const [isAdmin, setIsAdmin] = useState();
  const amountInputRef = useRef();

  useEffect(() => {
    setToken(localStorage.getItem("token"));
    setIsAdmin(localStorage.getItem("isAdmin"));
  }, [token]);

  const handleUpdate = (id) => {
    navigate("/update/" + id);
  };

  const handleDelete = async (id) => {
    try {
      const response = await axios.delete(`${baseURL}/delete/${id}`);
      console.log(response.data);
      if (response.data === "Product deleted!") {
        props.getProduct();
      }
    } catch (e) {
      console.log(e);
    }
  };

  const handleAddToCart = (product) => {
    console.log(amountInputRef.current.value);
    const product_item = {
      product: product,
      amount: amountInputRef.current.value,
    };
    dispatch(addToCart(product_item));
  };

  return (
    <>
      <Card
        sx={{
          width: 345,
          height: 550,
          display: "flex",
          justifyContent: "space-between",
          flexDirection: "column",
        }}
      >
        <CardHeader title={product.title} />
        <CardMedia
          component="img"
          height="194"
          image={product.images}
          alt="Product image"
        />
        <CardContent>
          <Stack direction="column" spacing={1}>
            <Typography variant="body2" color="text.secondary">
              {product.description}
            </Typography>
            <Stack direction="row" spacing={1}>
              <Rating
                name="half-rating-read"
                defaultValue={product.rating}
                precision={0.5}
                readOnly
              />
              <Typography variant="body1" color="text.primary">
                {product.rating}
              </Typography>
            </Stack>
            <Stack direction="column">
              <Typography variant="body1" color="text.primary">
                {product.price} $
              </Typography>
              <Typography variant="body1" color="text.primary">
                Price discount: {product.discountPercentage}%
              </Typography>
            </Stack>
          </Stack>
        </CardContent>

        <CardActions>
          {token && isAdmin ? (
            <Stack direction="row" gap={2}>
              <Button
                color="primary"
                variant="contained"
                onClick={() => handleUpdate(product._id)}
              >
                Update
              </Button>
              <Button
                color="error"
                variant="contained"
                onClick={() => handleDelete(product._id)}
              >
                Delete
              </Button>
            </Stack>
          ) : (
            <>
              <Stack direction="row" spacing={2}>
                <Button
                  variant="contained"
                  color="primary"
                  endIcon={<AddShoppingCartIcon />}
                  onClick={() => handleAddToCart(product)}
                >
                  + Add
                </Button>
                <TextField
                  inputRef={amountInputRef}
                  sx={{ width: 70 }}
                  label="Amount"
                  id={"amount_" + props.id}
                  type="number"
                  min={1}
                  max={5}
                  step={1}
                  defaultValue={1}
                />
              </Stack>
            </>
          )}
        </CardActions>
      </Card>
    </>
  );
};

export default ProductCard;

Updating your index.js file

Import the order.js Route into your index.js file and add it to your Middleware stack as follows:

screenshot screenshot

Adding orders to NavBar.jsx

Update your frontend/components/NavBar.jsx file as follows:


import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import Badge from "@mui/material/Badge";
import { IconButton } from "@mui/material";
import { useSelector } from "react-redux";
import logo from "../img/logo_2.png";
import ButtonBase from "@mui/material/ButtonBase";
import { AuthContext } from "../context/authContext";

const NavBar = () => {
  const navigate = useNavigate();
  const authContext = React.useContext(AuthContext);
  const items = useSelector((state) => state.cartStore.addedItems);
  const [token, setToken] = useState();
  const [isAdmin, setIsAdmin] = useState();

  useEffect(() => {
    setToken(localStorage.getItem("token"));
    setIsAdmin(localStorage.getItem("isAdmin"));
  }, [token]);

  const goToHome = () => {
    navigate("/");
  };

  const goToAddProduct = () => {
    navigate("/addProduct");
  };

  const goToLogin = () => {
    navigate("/login");
  };

  const logOut = () => {
    localStorage.clear();
    setIsAdmin();
    setToken();
    authContext.logout();
    navigate("/");
  };

  const goToCart = () => {
    navigate("/cart");
  };

  const goToOrders = () => {
    navigate("/orders");
  };

  return (
    <>
      <Box sx={{ flexGrow: 1 }}>
        <AppBar position="static" sx={{ background: "#38B6FF" }}>
          <Toolbar>
            <ButtonBase onClick={goToHome}>
              <Box
                component="img"
                sx={{ width: "8rem", height: "5rem" }}
                src={logo}
              />
            </ButtonBase>
            <Typography variant="h6" component="div" sx={{ flexGrow: 1 }} />
            {token && (
              <Button color="inherit" onClick={goToOrders}>
                Orders
              </Button>
            )}
            <IconButton onClick={goToCart}>
              <Badge badgeContent={items.length} color="secondary">
                <ShoppingCartIcon />
              </Badge>
            </IconButton>
            {isAdmin && (
              <Button color="inherit" onClick={goToAddProduct}>
                Add product
              </Button>
            )}
            {!token ? (
              <Button color="inherit" onClick={goToLogin}>
                Login
              </Button>
            ) : (
              <Button color="inherit" onClick={logOut}>
                LogOut
              </Button>
            )}
          </Toolbar>
        </AppBar>
      </Box>
    </>
  );
};
export default NavBar;

Adding orders to App.jsx

Paste the following into your App.jsx file.


import "./App.css";
import { Route, Routes } from "react-router-dom";
import RequiredAuth from "./util/authRoutes";

import HomePage from "./pages/HomePage";
import AddProductPage from "./pages/AddProductPage";
import UpdateProductPage from "./pages/UpdateProductPage";
import AuthPage from "./pages/AuthPage";

import CartPage from "./pages/CartPage";
import cartReducer from "./store/cart/cartReducer";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";

import { AuthContext } from "./context/authContext";
import { useState } from "react";
import AdminPage from "./pages/AdminPage";
import OrdersPage from "./pages/OrdersPage";

const store = configureStore({
  reducer: {
    cartStore: cartReducer,
  },
});

function App() {
  const [userLoggedData, setUserLoggedData] = useState({
    token: null,
    userId: null,
    isAdmin: false,
  });

  const login = (token, userId, isAdmin) => {
    //console.log("app token", token);
    localStorage.setItem("token", token);
    localStorage.setItem("userId", userId);
    setUserLoggedData({ token: token, userId: userId, isAdmin: isAdmin });
  };

  const logout = () => {
    setUserLoggedData({ token: null, userId: null, isAdmin: false });
    localStorage.removeItem("token");
    localStorage.removeItem("userId");
  };

  return (
    <Provider store={store}>
      <AuthContext.Provider
        value={{
          token: userLoggedData.token,
          userId: userLoggedData.userId,
          isAdmin: userLoggedData.isAdmin,
          login: login,
          logout: logout,
        }}
      >
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/login" element={<AuthPage />} />
          <Route path="/cart" element={<CartPage />} />
          {/* protected views*/}
          <Route
            path="/addProduct"
            element={
              <RequiredAuth>
                <AddProductPage />
              </RequiredAuth>
            }
          />
          <Route
            path="/update/:id"
            element={
              <RequiredAuth>
                <UpdateProductPage />
              </RequiredAuth>
            }
          />
          <Route
            path="/admin"
            element={
              <RequiredAuth>
                <AdminPage />
              </RequiredAuth>
            }
          />
          <Route
            path="/orders"
            element={
              <RequiredAuth>
                <OrdersPage />
              </RequiredAuth>
            }
          />
        </Routes>
      </AuthContext.Provider>
    </Provider>
  );
}

export default App;

Updating your HomePage.jsx file

Into your frontend/src/pages/HomePage.jsx file, paste the following:


import React, { useEffect, useState } from "react";
import axios from "axios";
import { Grid } from "@mui/material";

import NavBar from "../components/NavBar";
import ProductCard from "../components/ProductCard";

const baseURL = import.meta.env.VITE_BASE_URL;
axios.defaults.baseURL = baseURL;

const HomePage = () => {
  const [productList, setProductList] = useState([]);

  useEffect(() => {
    getProduct();
  }, []);

  const getProduct = async () => {
    try {
      const response = await axios.get(`${baseURL}/products`);
      setProductList(response.data);
      console.log(response.data);
    } catch (e) {
      console.log(e);
    }
  };

  return (
    <>
      <NavBar />
      <Grid container gap={3} sx={{ paddingTop: 2, paddingLeft: 3 }}>
        {productList.length !== 0 &&
          productList.map((product) => (
            <Grid item key={product._id}>
              <ProductCard
                key={product._id}
                product={product}
                getProduct={() => getProduct()}
              />
            </Grid>
          ))}
      </Grid>
    </>
  );
};

export default HomePage;

Verifying the order-taking features

Your home page should now look like that shown below.

screenshot

Verify the ability to order products follows:

  1. Click the LOGIN button at the top-right of the home page and sign in. screenshot
  2. You are returned to the home page, with the LOGOUT button displayed in the navbar. screenshot
  3. Click the Add button for a number of products. You should see the Shopping Cart icon update in the navbar. screenshot
  4. In the navbar, click the ORDERS button. You should see a screen like that below: screenshot

Unfortunately, there is no order history. And no ability to remove items from the Shopping Cart.

Project checklist

icon

CHECK that a user can login and logout of your app.

CHECK that ordering a product updates the Shopping Cart icon in the navbar.