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:
- In your frontend/src/ sub-folder, create a new folder named /store
- In this frontend/src/store sub-folder, create a new folder named /cart.
-
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";
-
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, }; };
-
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:
Creating an OrderPage.jsx
file
Into your frontend/src/pages sub-folder, download the following file:
Adding the ecommerce images
Follow the steps below:
-
Download the following three images:
emptycart.png
logo_2.png
not_found.png - In your frontend/src folder, create a new sub-folder named /img.
- 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:
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.
Verify the ability to order products follows:
- Click the LOGIN button at the top-right of the home page and sign in.
- You are returned to the home page, with the LOGOUT button displayed in the navbar.
- Click the Add button for a number of products. You should see the Shopping Cart icon update in the navbar.
- In the navbar, click the ORDERS button. You should see a screen like that below:
Unfortunately, there is no order history. And no ability to remove items from the Shopping Cart.
Project checklist
CHECK that a user can login and logout of your app.
CHECK that ordering a product updates the Shopping Cart icon in the navbar.