Shopi is an e-commerce application developed with React.js, Vite.js, and TailwindCSS. It allows users to explore products, add them to a shopping cart, and place orders. Navigation between application pages, such as home, user account, and orders, is managed with React Router DOM, while the global cart is handled using Context API, allowing access to selected products from any component.
Users can search for products by title, filter by categories, and review their order history. The shopping cart enables adding, removing products, and viewing the total in real-time.
- Creating modals in React.
- Managing routes with React Router DOM.
- Handling global states using Context API.
- Consuming APIs to display dynamic products.
- Designing responsive interfaces with TailwindCSS.
- Implementing logic to avoid duplicates in the shopping cart.
- Applying best practices in frontend development.
https://juancumbeq.github.io/platzi-react-with-vite-tailwind/
The tailwind documentation is key to properly proceed with the installation: https://tailwindcss.com/docs/guides/vite#react.
npm create vite@latest my-project
npm install -D tailwindcss postcss autoprefixer
###Â TailwindCSS init
npx tailwindcss init -p
###Â Configure TailwindCSS paths
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],@tailwind base;
@tailwind components;
@tailwind utilities;npm run devThe ecommerce application has different pages, such as:
- Home
- My Account
- My Order
- My Orders
- Not Found
- Sign In
Each page has its own folder where the index.js and the css code is stored.
App:
import {
useRoutes,
BrowserRouter,
} from 'react-router-dom';
// APPLICATION PAGES
import Home from '../Home';
import MyAccount from '../MyAccount';
import MyOrder from '../MyOrder';
import MyOrders from '../MyOrders';
import NotFound from '../NotFound';
import SignIn from '../SignIn';
import './App.css';
// ROUTES COMPONENT
const AppRoutes = () => {
let routes = useRoutes([
{ path: '/', element: <Home /> },
{
path: '/my-account',
element: <MyAccount />,
},
{ path: '/my-order', element: <MyOrder /> },
{ path: '/my-orders', element: <MyOrders /> },
{ path: '/*', element: <NotFound /> },
{ path: '/signin', element: <SignIn /> },
]);
return routes;
};
// MAIN APP COMPONENT
function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
export default App;As we can see, there are two components above. The first one defines the routes, meaning that based on the URL path, it will render one element or another. If the URL path is not defined, the default element rendered is <NotFound>.
The second component is the App component, which makes use of the <BrowserRouter>. The BrowserRouter component is essential for enabling routing. It provides the context and history objects that the routing hooks (useRoutes, useNavigate, etc.) rely on to function correctly.
The BrowserRouter component is the backbone of React Router. It listens to changes in the URL and interprets them to match the appropriate route. Without wrapping your routes inside BrowserRouter, any routing logic won't work as expected, and you'll likely encounter errors.
No, you cannot use AppRoutes alone without wrapping it in BrowserRouter. If you try to render AppRoutes without BrowserRouter, you'll encounter an error because useRoutes and other React Router hooks expect to be used within a Router component (like BrowserRouter, HashRouter, etc.).
Navbar:
import { NavLink } from 'react-router-dom';
function Navbar() {
const activeStyle =
'underline underline-offset-4';
return (
<nav className='flex justify-between items-center fixed z-10 w-full py-5 px-8 text-sm font-ligth'>
<ul className='flex items-center gap-3'>
<li className='font-semibold text-lg'>
<NavLink to='/'>Shopi</NavLink>
</li>
<li>
<NavLink
to='/'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
All
</NavLink>
</li>
<li>
<NavLink
to='/clothes'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Clothes
</NavLink>
</li>
<li>
<NavLink
to='/electronics'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Electronics
</NavLink>
</li>
<li>
<NavLink
to='/furnitures'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Furnitures
</NavLink>
</li>
<li>
<NavLink
to='/toys'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Toys
</NavLink>
</li>
<li>
<NavLink
to='/others'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Others
</NavLink>
</li>
</ul>
<ul className='flex items-center gap-3'>
<li className='text-black/60'>
test@platzi.com
</li>
<li>
<NavLink
to='/my-orders'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
My Orders
</NavLink>
</li>
<li>
<NavLink
to='/my-account'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
My Account
</NavLink>
</li>
<li>
<NavLink
to='/sign-in'
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Sign In
</NavLink>
</li>
<li>Cart 5</li>
</ul>
</nav>
);
}
export { Navbar };The navbar component is created using the Navlink component NavLink is a special type of link in react-router-dom that is used to create navigation links with special behavior when the link is active. It automatically applies an active class (or a custom class) when the link's target route matches the current URL.
The className function uses a conditional (ternary) operator to decide which class to apply based on the state isActive:
isActive ? activeStyle : undefined
If isActive is true, the string contained in activeStyle is returned. This applies the active CSS class, which is typically used to highlight the active link (e.g., bold or change the color).
More about Navlink: https://reactrouter.com/en/main/components/nav-link
The Layout component acts as a wrapper for every other page. In this way, we can apply the same styles to the entire web page.
Layout:
function Layout({ children }) {
return (
<div className='flex flex-col items-center mt-20'>
{children}
</div>
);
}
export { Layout };This component is going to be the window where the different products are displayed.
Card:
const Card = () => {
return (
<div className='bg-white cursor-pointer w-56 h-60 rounded-lg'>
<figure className='relative mb-2 w-full h-4/5'>
<span className='absolute bottom-0 left-0 bg-white/60 rounded-lg text-black text-xs m-2 px-3 py-0.5 '>
Electronics
</span>
<img
className='w-full h-full object-cover rounded-lg'
src='../../../images/pic01.jpg'
alt='headphones'
/>
<div className='absolute top-0 right-0 flex justify-center items-center bg-white w-6 h-6 rounded-full m-2 p-1'>
+
</div>
</figure>
<p className='flex justify-between'>
<span className='text-sm font-light'>
Headphones
</span>
<span className='text-lg font-medium'>
$300
</span>
</p>
</div>
);
};
export { Card };This project is built using fake data for product information; however, it is necessary to consume an API to retrieve that information.
The Home component retrieves the data and sets the local state with the array of objects received. The return statement executes the map() method to iterate over the array, rendering a Card component for each object found in the array.
The Card component receives the information as a prop, allowing access to all the specific details.
Home:
function Home() {
const [items, setItems] = useState(null);
useEffect(() => {
fetch(urlApi)
.then((response) => response.json())
.then((data) => setItems(data));
}, []);
console.log(items);
return (
<Layout>
Home
<div className='grid grid-cols-2 gap-6 w-full max-w-lg'>
{items?.map((item) => (
<Card key={item.id} data={item} />
))}
</div>
</Layout>
);
}
export default Home;Instead of using local state, it is better to use global state. This way, there is no possibility of prop drilling, and any component can access the context without needing props.
Context:
import { createContext } from 'react';
// CONTEXT CREATION
const ShoppingCartContext = createContext();
// COMPONENT PROVIDER
function ShoppingCartProvider({ children }) {
// RETURN STATEMENT USING CONTEXT PROVIDER
return (
<ShoppingCartContext.Provider
value={...}>
{children}
</ShoppingCartContext.Provider>
);
}
export { ShoppingCartContext, ShoppingCartProvider };As we can see, it is necessary to create a context. After that, by using another React component, the child components passed as props are wrapped inside the context provider. In this way, all the children can access the value data.
App:
// CONTEXT
import { ShoppingCartProvider } from '../../Context';
// MAIN APP COMPONENT
function App() {
return (
<ShoppingCartProvider>
<BrowserRouter>
<AppRoutes />
<Navbar />
</BrowserRouter>
</ShoppingCartProvider>
);
}In the App component, it is necessary to wrap all the other components to enable them to consume the value data.
In every e-commerce site, there is a shopping cart counter that shows the number of selected products. To create this feature, it is necessary to use the context in both the Card and Navbar components.
Context:
import { createContext, useState } from 'react';
// CONTEXT CREATION
const ShoppingCartContext = createContext();
// COMPONENT PROVIDER
function ShoppingCartProvider({ children }) {
// Global state cart counter
const [count, setCount] = useState(0);
// console.log('Count: ', count);
// RETURN STATEMENT USING CONTEXT PROVIDER
return (
<ShoppingCartContext.Provider
value={{ count, setCount }}
>
{children}
</ShoppingCartContext.Provider>
);
}
export {
ShoppingCartContext,
ShoppingCartProvider,
};Card:
import { useContext } from 'react';
// GLOBAL CONTEXT
import { ShoppingCartContext } from '../../Context';
const Card = ({ data }) => {
// LOCAL CONTEXT BASED ON GLOBAL CONTEXT
const context = useContext(ShoppingCartContext);
return (
{...}
<div
className='absolute top-0 right-0 flex justify-center items-center bg-white w-6 h-6 rounded-full m-2 p-1'
onClick={() =>
context.setCount(context.count + 1)
}
>
+
</div>
);
};
export { Card };Navbar:
<li>Cart {context.count}</li>As we can see, we can access count and setCount as a property of the consumed context, there is an explanation for that:
value={{ count, setCount }} defines the value that will be available in the context for any components that consume it. In this case, both count (the current state of the counter) and setCount (the function to update that counter) are accessible from any component that uses this context.
In the Card component, you are using useContext to access the context that you created earlier.
-
useContext(ShoppingCartContext)returns the current value of thecontext, which in this case is{ count, setCount }. -
context.setCount(context.count + 1)is usingsetCountto increment the value ofcountby 1.
You can access setCount as if it were a property of context, because this is how context works in React:
-
The value that the context provider
(ShoppingCartProvider)passes to its children (invalue={{ count, setCount }}) is an object containing bothcountandsetCount. -
When you use
useContext(ShoppingCartContext), you access this object. This allows you to usecontext.countto get the current value of thecounterandcontext.setCountto update it.
The following component represents the modal, which opens every time we click on a product to review its details.
Product Detail:
import './styles.css';
import { XCircleIcon } from '@heroicons/react/24/outline';
function ProductDetail() {
return (
<aside className='product-detail flex flex-col fixed right-0 border border-black rounded-lg bg-white'>
<div className='flex justify-between items-center p-6'>
<h2 className='font-medium text-xl'>
Detail
</h2>
<div>x</div>
<XCircleIcon className='size-6'></XCircleIcon>
</div>
</aside>
);
}
export { ProductDetail };The product details component is inserted in the home page.
Home:
<Layout>
Home
<div className='grid grid-cols-2 gap-6 w-full max-w-lg'>
{items?.map((item) => (
<Card key={item.id} data={item} />
))}
</div>
<ProductDetail />
</Layout>This is the icons library we are using in this project: https://heroicons.com/outline.
Following the documentation the command to install the module is:
npm install @heroicons/reactTo add an icon inside a component, this is an example:
Product Detail:
import './styles.css';
import { XCircleIcon } from '@heroicons/react/24/outline';
function ProductDetail() {
return (
<aside className='product-detail flex flex-col fixed right-0 border border-black rounded-lg bg-white'>
<div className='flex justify-between items-center p-6'>
<h2 className='font-medium text-xl'>
Detail
</h2>
<XCircleIcon className='size-6 text-black'></XCircleIcon>
</div>
</aside>
);
}
export { ProductDetail };Inside the context component, there is another React state to control whether the modal is open or not. Additionally, there are two other functions to set the modal status.
Context:
import { createContext, useState } from 'react';
// CONTEXT CREATION
const ShoppingCartContext = createContext();
// COMPONENT PROVIDER
function ShoppingCartProvider({ children }) {
// Global states
const [count, setCount] = useState(0);
const [
isProductDetailOpen,
setIsProductDetailOpen,
] = useState(false);
const openProductDetail = () =>
setIsProductDetailOpen(true);
const closeProductDetail = () =>
setIsProductDetailOpen(false);
// RETURN STATEMENT USING CONTEXT PROVIDER
return (
<ShoppingCartContext.Provider
value={{
count,
setCount,
openProductDetail,
closeProductDetail,
isProductDetailOpen,
}}
>
{children}
</ShoppingCartContext.Provider>
);
}
export {
ShoppingCartContext,
ShoppingCartProvider,
};In the Card component, the onClick event is set for every rendered card, updating the React state to true.
Card:
<div
className='bg-white cursor-pointer w-56 h-60 rounded-lg'
onClick={() => context.openProductDetail()}
></div>In the Product Details component, we are using template literals to set classes dynamically. Based on the isProductDetailOpen status, we can determine whether the component is visible or hidden.
Product details:
<aside
className={`${
isProductDetailOpen ? `flex` : `hidden`
} product-detail flex-col fixed right-0 border border-black rounded-lg bg-white`}
></aside>Once the Product Details component is rendered, it is important to show the product information. To achieve this, another global state is created inside the context file and exported within the value object.
Context:
// Product Detail - Show Product
const [productToShow, setProductToShow] =
useState({});Inside the Card component, there are some changes. Every time a card is clicked, the product data related to that specific card must be sent to the Product Details component to be rendered as well. This is why there is another function in the onClick() event.
Card:
// FUNCTION TO SEND DATA TO THE MODAL AND OPEN
const showProduct = (productDetail) => {
context.openProductDetail();
context.setProductToShow(productDetail);
};As the global state is updated with the product data using the Card component function, that data is now available to be consumed by the Product Details component.
Product Detail:
<figure className='px-6'>
<img
className='w-full h-full rounded-lg'
src={productToShow.image}
alt={productToShow.image}
/>
</figure>
<p className='flex flex-col p-6'>
<span className='font-medium text-2xl mb-2'>
${productToShow.price}
</span>
<span className='font-medium text-md mb-1'>
{productToShow.title}
</span>
<span className='font-light text-sm'>
{productToShow.description}
</span>
</p>Before displaying the products added by the user, it is necessary to create a global state to store them. Inside the context component, we create a React state initialized with an empty array.
Context:
// Shopping Cart - Add products to cart
const [cartProducts, setCartProducts] = useState(
[]
);In the Card component, we consume the context to access the array and the modifier method. We also create another function to be executed when the onClick() event occurs. This function modifies the counter and expands the cartProducts state array with the productData.
Card:
const addProductsToCart = (productData) => {
context.setCount(context.count + 1);
// Expands the array created on the state, adding the productData object
context.setCartProducts([
...context.cartProducts,
productData,
]);
console.log('CART: ', context.cartProducts);
};
<div
className='absolute top-0 right-0 flex justify-center items-center bg-white w-6 h-6 rounded-full m-2 p-1'
onClick={() => addProductsToCart(data)}
>
<PlusIcon className='size-6 text-black'></PlusIcon>
</div>;This new component is going to wrap all the selected items by clicking the + icon. It is similar to the Product Details component.
CheckoutSideMenu:
// STYLES
import './styles.css';
import { XCircleIcon } from '@heroicons/react/24/outline';
// CONTEXT
import { useContext } from 'react';
import { ShoppingCartContext } from '../../Context';
function CheckoutSideMenu() {
// CONTEXT STATES
const {
isCheckoutSideMenuOpen,
closeCheckoutSideMenu,
productToShow,
} = useContext(ShoppingCartContext);
return (
<aside
className={`${
isCheckoutSideMenuOpen ? `flex` : `hidden`
} checkout-side-menu flex-col fixed right-0 border border-black rounded-lg bg-white`}
>
<div className='flex justify-between items-center p-6'>
<h2 className='font-medium text-xl'>
My order
</h2>
<XCircleIcon
className='size-6 text-black cursor-pointer'
onClick={() => closeCheckoutSideMenu()}
></XCircleIcon>
</div>
</aside>
);
}
export { CheckoutSideMenu };In the context component, all the methods to open and close the checkout side menu modal are defined.
Context:
// Checkout side menu - Open/Close
const [
isCheckoutSideMenuOpen,
setIsCheckoutSideMenuOpen,
] = useState(false);
const openCheckoutSideMenu = () =>
setIsCheckoutSideMenuOpen(true);
const closeCheckoutSideMenu = () =>
setIsCheckoutSideMenuOpen(false);The Card component is the most important one because it is responsible for managing the events. The main div of the Card component handles the onClick() event by showing the product details modal, while the + icon manages the checkout side menu modal. To separate the events, we used the stopPropagation() method during the bubble phase. To learn more, check the information below.
Card:
const addProductsToCart = (
event,
productData
) => {
// Stopping the event handling by thw showProduct function
event.stopPropagation();
context.setCount(context.count + 1);
// Expands the array created on the state, adding the productData object
context.setCartProducts([
...context.cartProducts,
productData,
]);
// Checkout side menu
context.openCheckoutSideMenu();
context.closeProductDetail();
console.log('CART: ', context.cartProducts);
};
<div
className='absolute top-0 right-0 flex justify-center items-center bg-white w-6 h-6 rounded-full m-2 p-1'
onClick={(event) =>
addProductsToCart(event, data)
}
>
<PlusIcon className='size-6 text-black'></PlusIcon>
</div>;The Checkout Side Menu component is included in the App component to be available on different pages. To learn more about how the components are managed, check the information below.
App:
// MAIN APP COMPONENT
function App() {
return (
<ShoppingCartProvider>
<BrowserRouter>
<AppRoutes />
<Navbar />
<CheckoutSideMenu />
</BrowserRouter>
</ShoppingCartProvider>
);
}###Â stopPropagation()
The stopPropagation() method in JavaScript is used to control the flow of events in the DOM event model. Specifically, this method prevents an event from continuing to propagate through the DOM tree once it has been triggered.
To better understand how stopPropagation() works, it's important to know about the two phases of event propagation in the DOM event model:
- Capturing phase: The event propagates from the outermost element (the root of the document) down to the target element where the event occurred.
- Bubbling phase: After the event reaches the target element, it starts to propagate back up the DOM tree to the outermost element.
The stopPropagation() method is used to stop the event from propagating in either of these phases, preventing the event from being handled by any other elements. This ensures that only the current element processes the event, without affecting parent or child elements.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>stopPropagation Example</title>
<style>
.outer {
background-color: lightblue;
padding: 20px;
}
.inner {
background-color: lightcoral;
padding: 20px;
}
</style>
</head>
<body>
<div class="outer">
Outer Div
<div class="inner">Inner Div</div>
</div>
<script>
const outerDiv =
document.querySelector('.outer');
const innerDiv =
document.querySelector('.inner');
// Event on the outer container
outerDiv.addEventListener(
'click',
function () {
alert('Outer div clicked!');
}
);
// Event on the inner container
innerDiv.addEventListener(
'click',
function (event) {
alert('Inner div clicked!');
// Stop the event from propagating
event.stopPropagation();
}
);
</script>
</body>
</html>- When you click on the inner
div(with the classinner), the alert "Inner div clicked!" appears, andstopPropagation()is called. This stops the event from propagating, so the event handler attached to the outerdivdoes not execute. - If you remove
event.stopPropagation(), clicking on the innerdivwill also trigger the event on the outerdivdue to the bubbling phase.
- When you want an event to be handled by a specific element and don't want it to propagate to its parent elements.
- To prevent unintended behavior in other elements when an event is triggered on a child node.
It’s a useful tool to have fine control over how events are handled on a website.
###Â Components management in the App component
The AppRoutes component defines the application routes using useRoutes(). This hook from react-router-dom allows you to create an array of objects that map routes (with their respective path) to their corresponding components (element). The routes array in this case includes:
/→Homecomponent./my-account→MyAccountcomponent./my-order→MyOrdercomponent./my-orders→MyOrderscomponent./signin→SignIncomponent./*→NotFoundcomponent (a wildcard route to handle non-existent routes).
All of this is wrapped in the BrowserRouter, which manages the browser history and makes the routing functionality work properly.
You might wonder why the Navbar is placed below AppRoutes instead of inside each page or route. Here are several reasons for this decision:
-
Global Layout Strategy: The
Navbaris rendered once outside the routing system. This means that regardless of the current route (whether/,/my-account, etc.), theNavbarwill always be visible. Placing it belowAppRoutesensures that theNavbarremains fixed on all pages.- If you placed the
Navbarinside each individual component (likeHome,MyAccount, etc.), theNavbarwould re-render every time you navigate between pages, which is unnecessary and could affect performance.
- If you placed the
-
Better Organization and Maintenance: By placing the
Navbaroutside specific routes, you ensure it’s centralized and you don’t need to repeat the code in every page component. This simplifies the structure and makes maintenance easier.
The CheckoutSideMenu is also outside AppRoutes, just like the Navbar. This likely has to do with how it functions within the application:
-
Global Accessibility: Since the
CheckoutSideMenuis part of the shopping experience (e.g., a menu that shows products added to the cart), it should be accessible from any part of the app, regardless of which route the user is on. -
Global State: By placing it outside the routing system, the
CheckoutSideMenucan be controlled and displayed from any page without needing to be rendered directly in each one. TheShoppingCartProvider(which is the global context for the shopping cart) also wraps the entire app, meaningCheckoutSideMenucan easily consume the cart state from anywhere in the app.
- Imagine the user is on the
/my-accountpage but decides to open the cart via theCheckoutSideMenu. Placing this component outside the routing system ensures that it is available no matter what page you’re on, as it’s part of the global interface.
The App component is wrapped by the ShoppingCartProvider, which manages global state related to the shopping cart. All components within ShoppingCartProvider (including AppRoutes, Navbar, and CheckoutSideMenu) can access the global cart state. This allows any component to interact with the cart, which is especially important for both the CheckoutSideMenu and Navbar.
Navbar: It’s outside the routes to avoid unnecessary re-renders and to ensure it stays fixed across the entire application.CheckoutSideMenu: It’s placed in the root so that it’s available and accessible from any part of the app, regardless of the current page.useRoutes(): Manages the main routes, but global components likeNavbarandCheckoutSideMenuare independent of the routes, providing a better user experience by always being available.
This design follows a global layout pattern, where certain components (such as the navigation bar and important menus) are rendered only once and remain visible throughout the entire app.
Placing the Navbar above AppRoutes in the code order would not affect the rendering or functionality of the application in terms of routing or the visibility of the Navbar. In React, the order in which components appear in the code does not necessarily reflect the order in which they are rendered visually on the page.
Instead of this code:
<BrowserRouter>
<AppRoutes />
<Navbar />
<CheckoutSideMenu />
</BrowserRouter>You could have it like this:
<BrowserRouter>
<Navbar />
<AppRoutes />
<CheckoutSideMenu />
</BrowserRouter>-
DOM Order: Even if you change the order in which components are written, what really matters is the DOM and CSS layout. The
Navbarwill still be present on the page and will remain visible because it is outside of the specific routes and does not depend on them.- If you place the
NavbarbeforeAppRoutes, it will appear earlier in the component tree, but visually it will depend on the CSS or styles applied for positioning (e.g., if it's fixed at the top of the page, it will always stay in that position).
- If you place the
-
React Rendering: React renders components in the order they appear in the JSX tree, but there is no technical issue with placing the
Navbareither before or afterAppRoutes, as long as both are wrapped byBrowserRouter. React will continue updatingAppRouteswhen the route changes, and theNavbarwill stay on the screen as it is not affected by route changes.
If you decide to change the order to:
<BrowserRouter>
<Navbar />
<AppRoutes />
<CheckoutSideMenu />
</BrowserRouter>This new order will not change the expected visual behavior because the Navbar will still be present and accessible on all routes. The same applies to CheckoutSideMenu.
The only case where it might affect rendering is if there are specific behaviors or styles that depend on the rendering order in the DOM, such as:
- If the
Navbarhas CSS styles that depend on being before or after another component (likeposition: relative,z-index, etc.), there might be a visual impact. - If
AppRoutesor the routes contain components that depend on some state managed within theNavbar, you might need to manage the mounting order. However, for a navigation bar like the one described, this should not be an issue.
Once we click on the + icon the selected product must be added to my order view. The order card component represents each product.
OrderCard:
import { XMarkIcon } from '@heroicons/react/24/outline';
const OrderCard = (props) => {
const { id, title, imageURL, price } = props;
return (
<div className='flex justify-between items-center mb-3'>
<div className='flex items-center gap-2'>
<figure className='w-20 h-20'>
<img
className='w-full h-full rounded-lg object-cover'
src={imageURL}
alt={title}
/>
</figure>
<p className='text-sm font-light'>
{title}
</p>
</div>
<div className='flex items-center gap-2'>
<p className='text-lg font-medium'>
{price}
</p>
<XMarkIcon className='h-6 w-6 text-black cursor-pointer'></XMarkIcon>
</div>
</div>
);
};
export { OrderCard };The checkout side menu can access the cartProducts array and iterate it to render an order card component for every element.
CheckoutSideMenu:
function CheckoutSideMenu() {
// CONTEXT STATES
const {
isCheckoutSideMenuOpen,
closeCheckoutSideMenu,
cartProducts,
} = useContext(ShoppingCartContext);
console.log('CART: ', cartProducts);
return (
<aside
className={`${
isCheckoutSideMenuOpen ? `flex` : `hidden`
} checkout-side-menu flex-col fixed right-0 border border-black rounded-lg bg-white`}
>
<div className='flex justify-between items-center p-6'>
<h2 className='font-medium text-xl'>
My order
</h2>
<XCircleIcon
className='size-6 text-black cursor-pointer'
onClick={() => closeCheckoutSideMenu()}
></XCircleIcon>
</div>
<div className='px-6'>
{cartProducts.map((product) => (
<OrderCard
key={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
/>
))}
</div>
</aside>
);
}
export { CheckoutSideMenu };At this point, we can add products to the shopping cart without any limitations, but we need to set the application to avoid duplicate products in the cart.
Additionally, after adding a product, the + icon must change to a check icon, indicating that the product has already been added.
In the Card component, there is another function handling this situation. If the product is already in the cartProducts array, the check icon is rendered; otherwise, the plus icon is displayed.
The filter() method returns a new array with the results found, which is why we apply the length property to determine if there are any results.
Card:
const renderIcon = (id) => {
const isInCart =
context.cartProducts.filter(
(product) => product.id === id
).length > 0;
if (isInCart) {
return (
<div className='absolute top-0 right-0 flex justify-center items-center bg-black w-6 h-6 rounded-full m-2 p-1'>
<CheckIcon className='size-6 text-white'></CheckIcon>
</div>
);
} else {
return (
<div
className='absolute top-0 right-0 flex justify-center items-center bg-white w-6 h-6 rounded-full m-2 p-1'
onClick={(event) =>
addProductsToCart(event, data)
}
>
<PlusIcon className='size-6 text-black'></PlusIcon>
</div>
);
}
};Now we can add products to our cart, but it is necessary to remove them at any time. To do that, we need to set the onClick() event to delete the selected product from the cartProducts array.
In the Checkout Side Menu, we handle this event by passing the handleDelete function as a prop to the Order Card component.
CheckoutSideMenu:
import { OrderCard } from '../OrderCard';
// STYLES
import './styles.css';
import { XCircleIcon } from '@heroicons/react/24/outline';
// CONTEXT
import { useContext } from 'react';
import { ShoppingCartContext } from '../../Context';
function CheckoutSideMenu() {
// CONTEXT STATES
const {
isCheckoutSideMenuOpen,
closeCheckoutSideMenu,
cartProducts,
setCartProducts,
} = useContext(ShoppingCartContext);
// DELETE CART PRODUCTS
const handleDelete = (id) => {
const filteredProducts = cartProducts.filter(
(product) => product.id != id
);
setCartProducts(filteredProducts);
};
return (
<aside
className={`${
isCheckoutSideMenuOpen ? `flex` : `hidden`
} checkout-side-menu flex-col fixed right-0 border border-black rounded-lg bg-white`}
>
<div className='flex justify-between items-center p-6'>
<h2 className='font-medium text-xl'>
My order
</h2>
<XCircleIcon
className='size-6 text-black cursor-pointer'
onClick={() => closeCheckoutSideMenu()}
></XCircleIcon>
</div>
<div className='px-6 overflow-y-scroll'>
{cartProducts.map((product) => (
<OrderCard
key={product.id}
id={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
handleDelete={handleDelete}
/>
))}
</div>
</aside>
);
}
export { CheckoutSideMenu };In the Order Card, we receive various props, one of which is the function handleDelete. The parameter is necessary to know which product needs to be deleted.
OrderCard:
<XMarkIcon
className='h-6 w-6 text-black cursor-pointer'
onClick={() => handleDelete(id)}
></XMarkIcon>In every checkout, there is a total sum of the selected products. To achieve this, we use another function called totalPrice(), which is built in a .js file.
Utils:
/**
* This function calculates total prices of a new order
* @param {Array} products cartProducts: Array of Objects
* @returns {number} Total price
*/
const totalPrice = (products) => {
let sum = 0;
products.forEach(
(product) => (sum += product.price)
);
return sum;
};
export { totalPrice };In the Checkout Side Menu component, the function totalPrice() is imported and then used below, with the cartProducts array as an argument.
CheckoutSideMenu:
<div className='px-9 mt-6'>
<p className='flex justify-between items-center'>
<span className='font-light'>Total:</span>
<span className='font-medium text-xl'>
${totalPrice(cartProducts)}
</span>
</p>
</div>Once we have all the selected products, we can proceed with the checkout. In our application, this means adding the entire cart to an orders list. To do this, there must be another React state in the context component so that we can manipulate that state from other parts of the application.
Context:
// Shopping Cart - Orders
const [order, setOrder] = useState([]);In the Checkout Side Menu component, there is a button that will execute a function to handle the order addition.
CheckoutSideMenu:
<button
className='bg-black py-3 text-white w-full rounded-lg'
onClick={() => handleCheckout()}
>
Checkout
</button>The function creates an object based on the current cart details, adds the current order to an array, and clears the cartProducts, making it available to store another set of products.
const handleCheckout = () => {
// current order details
const orderToAdd = {
date: '01,02,23',
products: cartProducts,
totalProducts: cartProducts.length,
totalPrice: totalPrice(cartProducts),
};
// adding the current order
setOrder([...order, orderToAdd]);
// cleaning the checkout cart
setCartProducts([]);
};Now we have the React state order available to store several shopping carts, but it is necessary to show the last order on another page to review the details. To do this, the Checkout button must direct the user to the URL: /my-order/last.
The Checkout Side Menu component uses the Link component to enable the redirection.
CheckoutSideMenu:
<Link to='/my-orders/last'>
<button
className='bg-black py-3 text-white w-full rounded-lg'
onClick={() => {
handleCheckout();
closeCheckoutSideMenu();
}}
>
Checkout
</button>
</Link>The MyOrder component accesses the order array and extracts the last element, which is the last order. It then accesses the products property of that element, which is another array. This array contains the cartProducts information, allowing us to iterate over it and render the OrderCard component.
MyOrder:
// COMPONENTS
import { Layout } from '../../Components/Layout';
import { OrderCard } from '../../Components/OrderCard';
// CONTEXT
import { useContext } from 'react';
import { ShoppingCartContext } from '../../Context';
function MyOrder() {
const { order } = useContext(
ShoppingCartContext
);
return (
<Layout>
<div className=''>MyOrder</div>
<div className='flex flex-col w-80'>
{order
?.slice(-1)[0]
.products.map((product) => (
<OrderCard
key={product.id}
id={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
/>
))}
</div>
</Layout>
);
}
export default MyOrder;In the following line of code:
{
order
?.slice(-1)[0]
.products.map((product) => (
<OrderCard
key={product.id}
id={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
/>
));
}You're chaining several operations to display the products from the latest order in the shopping cart. Below there is an explanation of the potential issues or pitfalls in this line:
The optional chaining operator ?. is used to prevent errors if order is null or undefined. If order is null, everything after the optional chaining stops executing and returns undefined, avoiding any crashes in the app when order is not defined.
Potential issue: If order is null or undefined, the block won't render anything since there is no array to process. This may lead to the component not displaying anything when there are no orders available.
The method slice(-1) returns a new array that contains only the last element of order. Since slice always returns an array, [0] is used after it to access the first (and only) element of that new array, which is the latest order.
Potential issue:
- If
orderis an empty array ([]),slice(-1)will return another empty array, and when you try to access[0], you'll getundefined. This could lead to errors when trying to access.productsonundefined.
In the next line, you assume that the value returned by slice(-1)[0] (the latest order) has a products property, which is an array of products.
Potential issue:
- If
order.slice(-1)[0]ends up beingundefined(for example, iforderis an empty array), accessing.productswill throw an error because you're trying to access a property onundefined. This would break the application.
To avoid this issue, you could use optional chaining here as well:
order?.slice(-1)[0]?.products.map(...)This ensures that you only try to access .products if there is a valid last order and you're not trying to access a property on undefined.
If everything works correctly up to this point, products.map will iterate over each product in the last order and generate an OrderCard component for each one.
Potential issue:
- If
productsis not an array (for example, if it'sundefinedor doesn't exist),mapwill throw an error because you can't iterate over a non-array value.
To fix this, you can ensure that products is always at least an empty array when there are no products, so that map doesn't fail:
{
order
?.slice(-1)[0]
?.products?.map((product) => (
<OrderCard
key={product.id}
id={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
/>
));
}The My Orders page is going to wrap all the orders. We are iterating over the ordersList, rendering a clickable OrderCard component. Each order will direct the user to a MyOrder page with the specific order selected.
MyOrders:
import { Layout } from '../../Components/Layout';
import { OrdersCard } from '../../Components/OrdersCard';
import { Link } from 'react-router-dom';
import { React, useContext } from 'react';
import { ShoppingCartContext } from '../../Context';
function MyOrders() {
const { ordersList } = useContext(
ShoppingCartContext
);
return (
<Layout>
<div className='flex items-center justify-center relative w-80'>
<h1>MyOrders</h1>
</div>
{ordersList?.map((order, index) => (
<Link
key={index}
to={`/my-orders/${order.id}`}
>
<OrdersCard
className='cursor-pointer'
date={order.date}
totalPrice={order.totalPrice}
totalProducts={order.totalProducts}
/>
</Link>
))}
</Layout>
);
}
export default MyOrders;This component will be rendered at every iteration of ordersList.
OrdersCard:
import { XMarkIcon } from '@heroicons/react/24/outline';
const OrdersCard = (props) => {
const { date, totalPrice, totalProducts } =
props;
return (
<div className='flex justify-between items-center mb-3 border border-black'>
<p>
<span>{date} </span>
<span>{totalPrice} </span>
<span>{totalProducts} </span>
</p>
</div>
);
};
export { OrdersCard };On the other hand, to avoid reloading the entire page and losing the ordersList information (as there is no persistence set up yet), the Link component will direct us to the My Orders page.
MyOrder:
// COMPONENTS
import { Layout } from '../../Components/Layout';
import { OrderCard } from '../../Components/OrderCard';
import { Link } from 'react-router-dom';
import { ChevronLeftIcon } from '@heroicons/react/24/solid';
// CONTEXT
import { useContext } from 'react';
import { ShoppingCartContext } from '../../Context';
function MyOrder() {
const { ordersList } = useContext(
ShoppingCartContext
);
return (
<Layout>
<div
className='flex items-center justify-center relative
w-80 mb-6'
>
<Link
to={'/my-orders'}
className='absolute left-0'
>
<ChevronLeftIcon className='h-6 w-6 text-black cursor-pointer' />
</Link>
<h1 className=''>MyOrder</h1>
</div>
<div className='flex flex-col w-80'>
{ordersList
?.slice(-1)[0]
.products.map((product) => (
<OrderCard
key={product.id}
id={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
/>
))}
</div>
</Layout>
);
}
export default MyOrder;Now we have the order list rendered on the My Orders page. The goal is to click on each order and see its content, specifically the selected products. To achieve this, we are redirected to the URL /my-order/{index}, where the index refers to the position in the ordersList array.
MyOrders:
return (
<Layout>
<div className='flex items-center justify-center relative w-80'>
<h1>MyOrders</h1>
</div>
{ordersList?.map((order, index) => (
<Link
key={index}
to={`/my-orders/${index}`}
>
<OrdersCard
className='cursor-pointer'
date={order.date}
totalPrice={order.totalPrice}
totalProducts={order.totalProducts}
/>
</Link>
))}
</Layout>
);It is necessary to specify in the App component the route.
App:
{
path: '/my-orders/:id',
element: <MyOrder />,
},When React renders the MyOrder page, it accesses the URL to extract the :id sent. This id will help us determine the position in the ordersList array. If the id is last, as when we click the checkout button, the index variable is assigned the last position of the array.
MyOrder:
function MyOrder() {
const { ordersList } = useContext(
ShoppingCartContext
);
// URL INDEX
const currentPath = window.location.pathname;
let index = currentPath.substring(
currentPath.lastIndexOf('/') + 1
);
// Last case
if (index === 'last') {
index = ordersList?.length - 1;
}
return (
<Layout>
<div
className='flex items-center justify-center relative
w-80 mb-6'
>
<Link
to={'/my-orders'}
className='absolute left-0'
>
<ChevronLeftIcon className='h-6 w-6 text-black cursor-pointer' />
</Link>
<h1 className=''>MyOrder</h1>
</div>
<div className='flex flex-col w-80'>
{ordersList?.[index].products.map(
(product) => (
<OrderCard
key={product.id}
id={product.id}
title={product.title}
imageURL={product.image}
price={product.price}
/>
)
)}
</div>
</Layout>
);
}At this point there is the need to give style to the order cards.
OrderCard:
import { XMarkIcon } from '@heroicons/react/24/outline';
import { ChevronRightIcon } from '@heroicons/react/24/outline';
import { CalendarIcon } from '@heroicons/react/24/solid';
import { ShoppingBagIcon } from '@heroicons/react/24/solid';
const OrdersCard = (props) => {
const { date, totalPrice, totalProducts } =
props;
let articles =
totalProducts == 1 ? 'article' : 'articles';
return (
<div className='flex justify-between items-center border border-black rounded-lg p-4 w-80 mb-4'>
<div className='flex justify-between w-full'>
<div className='flex flex-col'>
<p className='flex items-center gap-2'>
<CalendarIcon className='size-5' />
<span className='font-light'>
{date}
</span>
</p>
<p className='flex items-center gap-2'>
<ShoppingBagIcon className='size-5' />
<span className='font-light'>
{totalProducts} {articles}
</span>
</p>
</div>
<p className='flex items-center gap-2'>
<span className='font-medium text-2xl'>
$ {totalPrice}
</span>
<ChevronRightIcon className='h-6 w-6 text-black cursor-pointer' />
</p>
</div>
</div>
);
};
export { OrdersCard };Once we have the app running, we need to refactor the API request, which means relocating the request to the context component instead of the Home page. At the same time, another global state is created to store the word written in the search input.
Context:
// API Products
const [apiItems, setApiItems] = useState(null);
useEffect(() => {
fetch(urlApi)
.then((response) => response.json())
.then((data) => setApiItems(data));
}, []);
// Search products by title
const [searchByTitle, setSearchByTitle] =
useState(null);In the Home page, we apply some changes, such as creating the search input and setting the onChange() event, which will update the global state.
Home:
function Home() {
const {
apiItems,
searchByTitle,
setSearchByTitle,
} = useContext(ShoppingCartContext);
console.log(searchByTitle);
return (
<Layout>
<div className='flex items-center justify-center relative w-80 mb-4'>
<h1 className='font-medium text-xl'>
All Products
</h1>
</div>
<div className='flex flex-col'>
<input
type='text'
placeholder='Search a product'
className='text-center rounded-lg border border-black w-full p-3 mb-4 focus:outline-none'
onChange={(event) =>
setSearchByTitle(event.target.value)
}
/>
<div className='grid grid-cols-4 gap-6 w-full max-w-screen-lg'>
{apiItems?.map((item) => (
<Card key={item.id} data={item} />
))}
</div>
</div>
<ProductDetail />
</Layout>
);
}To filter products using the words written in the input tag, it is necessary to execute a function that returns another array with the matches. This function is filterItemsByTitle().
However, the useEffect() hook needs to be used because filterItemsByTitle() has to be executed every time the apiItems and searchByTitle dependencies change.
The resulting array is stored in filteredItems.
Context:
// Filtered products - search results
const [filteredItems, setFilteredItems] =
useState(null);
// Filtering process
const filterItemsByTitle = (
apiItems,
searchByTitle
) => {
return apiItems?.filter((item) =>
item.title
.toLowerCase()
.includes(searchByTitle.toLowerCase())
);
};
useEffect(() => {
if (searchByTitle) {
setFilteredItems(
filterItemsByTitle(apiItems, searchByTitle)
);
}
}, [apiItems, searchByTitle]);In the Home page, we render the products array, which can either be the array returned by the API or the filtered items array. We apply logic to render one or the other based on the length of the arrays.
Home:
function Home() {
const {
apiItems,
searchByTitle,
setSearchByTitle,
filteredItems,
} = useContext(ShoppingCartContext);
// Products view render
const renderView = () => {
if (searchByTitle?.length > 0) {
if (filteredItems?.length > 0) {
return filteredItems?.map((item) => (
<Card key={item.id} data={item} />
));
} else {
return (
<div className='flex flex-col items-center justify-center col-span-4 mt-6'>
<InformationCircleIcon className='h-6 w-6 mb-2' />
<p>No products found</p>
</div>
);
}
} else {
return apiItems?.map((item) => (
<Card key={item.id} data={item} />
));
}
};
// SIMPLIFIED CODE
// const renderViewProducts = () => {
// const itemsToRender =
// searchByTitle?.length > 0
// ? filteredItems
// : apiItems;
// if (itemsToRender?.length > 0) {
// return itemsToRender?.map((item) => (
// <Card key={item.id} data={item} />
// ));
// } else {
// return <p> No results Found</p>;
// }
// };
return (
<Layout>
<div className='flex items-center justify-center relative w-80 mb-4'>
<h1 className='font-medium text-xl'>
All Products
</h1>
</div>
<div className='flex flex-col w-full max-w-screen-lg'>
<input
type='text'
placeholder='Search a product'
className='text-center rounded-lg border border-black w-full p-3 mb-4 focus:outline-none'
onChange={(event) =>
setSearchByTitle(event.target.value)
}
/>
<div className='grid grid-cols-4 gap-6 w-full max-w-screen-lg'>
{renderView()}
</div>
</div>
<ProductDetail />
</Layout>
);
}
export default Home;Filtering products by categories is mainly executed in the Context component; the App component only defines the routes and the elements that will be rendered (Home page).
App:
const AppRoutes = () => {
let routes = useRoutes([
{ path: '/', element: <Home /> },
{ path: '/clothes', element: <Home /> },
{ path: '/electronics', element: <Home /> },
{ path: '/jewelery', element: <Home /> },
{ path: '/toys', element: <Home /> },
{ path: '/others', element: <Home /> },
{ path: '/', element: <Home /> },
{
path: '/my-account',
element: <MyAccount />,
},
{ path: '/my-order', element: <MyOrder /> },
{
path: '/my-orders',
element: <MyOrders />,
},
{
path: '/my-orders/last',
element: <MyOrder />,
},
{
path: '/my-orders/:id',
element: <MyOrder />,
},
{ path: '/*', element: <NotFound /> },
{ path: '/signin', element: <SignIn /> },
]);
return routes;
};On the other hand, the Navbar component sets a category in the global state searchByCategory for every Link tag; this value will be used to filter products.
Navbar:
<li>
<NavLink
to='/'
onClick={() =>
context.setSearchByCategory(null)
}
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
All
</NavLink>
</li>
<li>
<NavLink
to='/clothes'
onClick={() =>
context.setSearchByCategory(
"men\'s clothing"
)
}
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Clothes
</NavLink>
</li>
<li>
<NavLink
to='/electronics'
onClick={() =>
context.setSearchByCategory(
'electronics'
)
}
className={({ isActive }) =>
isActive ? activeStyle : undefined
}
>
Electronics
</NavLink>
</li>The Context component is where all the filtering processes are made. After setting the states and the filter functions, we create another function called filterBy(), which will assign the apiItems array to filteredResults. After that, the filter by category is executed, and the result is assigned to filteredResults.
The next filtering by title process is based on the previous results.
In case both searches are false, the apiItems array is returned.
Context:
// Search products by title / category
const [searchByTitle, setSearchByTitle] =
useState(null);
const [searchByCategory, setSearchByCategory] =
useState(null);
// Filtered products - search results
const [filteredItems, setFilteredItems] =
useState(null);
// Filtering process by title
const filterItemsByTitle = (
apiItems,
searchByTitle
) => {
return apiItems?.filter((item) =>
item.title
.toLowerCase()
.includes(searchByTitle.toLowerCase())
);
};
// Filtering process by category
const filterItemsByCategory = (
apiItems,
searchByCategory
) => {
return apiItems?.filter((item) =>
item.category
.toLowerCase()
.includes(searchByCategory.toLowerCase())
);
};
// Filter process
const filterBy = (
apiItems,
searchByTitle,
searchByCategory
) => {
let filteredResults = apiItems;
if (searchByCategory) {
filteredResults = filterItemsByCategory(
apiItems,
searchByCategory
);
}
if (searchByTitle) {
filteredResults = filterItemsByTitle(
filteredResults,
searchByTitle
);
}
return filteredResults;
};
useEffect(() => {
setFilteredItems(
filterBy(
apiItems,
searchByTitle,
searchByCategory
)
);
}, [apiItems, searchByTitle, searchByCategory]);From the previous header, the Home page has changed. Now, the renderView() function only executes the render, regardless of whether it is the apiItems array or the filtered one.
Home:
const {
setSearchByTitle,
filteredItems: itemsToRender,
} = useContext(ShoppingCartContext);
// Products view render
const renderView = () => {
if (itemsToRender?.length > 0) {
return itemsToRender?.map((item) => (
<Card key={item.id} data={item} />
));
} else {
return (
<div className='flex flex-col items-center justify-center col-span-4 mt-6'>
<InformationCircleIcon className='h-6 w-6 mb-2' />
<p>No products found</p>
</div>
);
}
};- The application base url is not working properly, it is taking: https://juancumbeq.github.io/platzi-react-with-vite-tailwind/ the entire url as a internal route of the application.
This project was developed by Juan Cumbe. If you have any questions or suggestions about the project, feel free to contact me via e-mail or my Linkedin profile.