Table of Contents
Ready to tackle Frontend Mentor’s E-commerce product page challenge project?
DM has informed me you can hear crickets around THT these days—it’s apparently unacceptable, etc. etc.
He clearly has a point and isn’t too shy to make it 🙄
It has been ages since the last project, I thoroughly apologize. I’ve been working consistently on a few large projects that are challenging in every way possible.
There aren’t enough hours in a day to tackle passions and work even when your passions are, in fact, your work.
Anyway, as a nice break, I thought to take on one of the free Frontend Mentor challenges. I was aiming for something easy but ended up with intermediate 😬
In today’s tutorial, I’ll walk you through my process of building a modern e-commerce product page using React and Vite.
Check out the live demo of the completed e-commerce landing page right here.
The challenge objective is to build an e-commerce product page as close to the provided design as possible.
Note 👁️
Figma access to the design is limited to Pro members so we’re going to rely on our discerning eyes to replicate the provided design images.
Per the project description, users should be able to:
- Open a lightbox gallery by clicking on the large product image
- Switch the large product image by clicking on the small thumbnail images
- Add to and remove items from the cart
- View the cart and its items
A few notable new features we haven’t seen on previous THT projects are the lightbox gallery and custom React hooks!
Hey!
We’ve built a cool cart before, checkout out How To Make A Really Good Shopping Cart With React for the tutorial deets.
With this project, we’ll sharpen our front-end development skills in responsive design, state management, and component reusability 🙌
We, also, will remedy the content drought 😬
Setting up the project
Let’s start by setting up the dev environment. I use Vite to quickly scaffold this project since Create React App was deprecated in 2023.
Feel free to use whichever alternative you prefer, especially if you’re inspired to build this into a multi-page application!
After installing Vite, I’ll set a project structure by placing assets in a static directory and components in the src directory.
What typically goes in the public or static directory?
Do you sometimes get confused about what images go in the public directory of a React app vs what goes in the assets folder within the src directory?
Here’s a breakdown:
Public directory
- Contains static assets that aren’t processed by Webpack or any build tool and are served to the client without any transformations.
- Holds favicons (i.e. the website’s icon in the browser tab), manifest.json and various icon files used for progressive web apps, and external images not used directly by the application (e.g. don’t require optimization to serve).
Assets folder in the src directory
- Stores assets that are part of the project source code so they’re processed by Webpack and can be imported directly into JavaScript or CSS files.
- Holds images used in components like logos, illustrations, or background images along with any other image asset that should be performance optimized when served to the client.
My current directory structure looks like so:
- src
- assets
// product images to optimize
- components
- context
- helpers
- hooks
- icons
// svg icons turned react components for customization
- App.jsx
- main.jsx
- static
// svg icons
It might seem a tad more involved than necessary for a single product page, but I want to show you how the project structure will evolve for a more nuanced application.
Working on the nav component
The nav we need to build consists of a logo, menu, cart, and profile. From these components, the menu and cart are what might be considered robust.
I’ll begin with the navigation menu and its two distinct layouts: a full menu for large screens and a panel menu for mobile devices.
While the full menu is rather straightforward, it needs to collapse into a side panel on mobile. To do this, I create two components, a PanelNavMenu
and a FullNavMenu
, and use media queries to determine display.
Tip 👇
For more menu side panel practice checkout out How To Make A Collapsible Sidebar Menu For American Airlines.
We’re using an icon for the hamburger menu indicator on mobile as it’s a provided asset. If you’d like more control over the behavior of the hamburger, visit How To Build An Easy Animated Hamburger Menu.
Then, I’ll add a cart component with a clickable cart icon that displays a dropdown cart list.
To dismiss the cart list on click outside of the component, we’ll create a custom useOnClickOutside
hook.
export default function useOnClickOutside(ref, callback) {
useEffect( ()=>{
function listener(e) {
let target = e.target;
if(!ref.current || ref.current.contains(target)) return;
callback(e);
}
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
}
}, [ref, callback]);
Custom hooks are reusable code blocks that allow us to add functionality without repeating code. As an FYI, we’ll be using quite a few custom hooks on this project so be prepared!
Building the main component
With the nav component out of the way, we’re ready to move on to the main component which consists of the product display and product information.
Product display
I’ll start by creating the product display first which is broken into a main image container along a series of thumbnails.
Each thumbnail will be clickable, controlling the enlarged display of its respective image in the main image container.
The thumbnails should collapse on smaller screens with navigation among the product images shifting onto arrow icons in a gallery slider format.
export default function ProductDisplay(props) {
const [selectedItem, setSelectedItem] = useState(1);
const { isLbShowing, onChange } = useMyContext(LightboxContext);
const { width } = useWindowSize();
const lightboxHandler = () => {
if (width <= 1210 || isLbShowing) return; // prevent click on lightbox product
onChange(true);
};
return (
<div className={`${s.productDisplayWrapper} ${props?.lb && s.lbx}`}>
<div
className={`${s.productMain}`}
onClick={lightboxHandler}
>
<img src={assets["prod_" + selectedItem]} alt={`Product ${selectedItem}`} />
</div>
<ArrowNav
cs={s.productMobArrowNav}
currentIndex={selectedItem}
onSelect={setSelectedItem}
/>
<div className={`${s.thumbnailWrapper}`}>
{Array.from({ length: 4 }).map((_, idx) => {
const item = idx + 1;
return (
<ProductThumbnail
item={item}
key={idx}
selected={selectedItem}
onSelect={setSelectedItem}
/>
);
})}
</div>
</div>
);
}
Now, quite a few things are going on in ProductDisplay
above so let’s break it down:
- A default prop,
lb
, is used to determine whetherProductDisplay
is part of the lightbox component to prevent triggering the lightbox display - The
ArrowNav
component usescurrentIndex
to keep track of which item is selected for display - The
ProductThumbnail
component controls the display of the product thumbnails and syncs theselectedItem
selectedItem
manages the state of the displayed product which gets updated byArrowNav
andProductThumbnail
respectively
Note 👀
I’m simulating structured data by utilizingArray.from()
to generate product thumbnails efficiently. For more on built-in array methods, check out A Dirty Quick Refresher On JavaScript Array Methods You Need To Know.
I’m using two custom hooks in this component: useMyContext
and useWindowSize
. Both are utility hooks with the first returning an instantiated useContext
of the provided context and the latter determining window size.
I created a context for the lightbox that I use with its corresponding provider to access the lightbox state across various components.
export default function LightboxProvider({children}) {
const [state, setState] = useState(false);
const lightboxContext = {
isLbShowing: state,
onChange: bool => setState(bool)
}
return (
<LightboxContext.Provider value={lightboxContext}>
{children}
</LightboxContext.Provider>
)
}
For more on working with React Context check out How To Build A Really Stunning Quote App With React.
Lightbox component
The project requires we build a lightbox gallery that displays on click of the main image container so let’s do that next.
export default function LightBox() {
const { isLbShowing, onChange } = useMyContext(LightboxContext);
const dismissModalHandler = () => {
onChange(false);
};
useEffect(() => {
function handleScroll() {
if (isLbShowing) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
}
handleScroll();
return () => (document.body.style.overflow = "auto");
}, [isLbShowing]);
return (
<Modal onDismiss={dismissModalHandler}>
<ProductDisplay lb="true" />
</Modal>
);
}
I’m reusing the ProductDisplay
component with the lb
custom property set to true since we’ll incorporate it in the lightbox.
In the useEffect
, I prevent scroll of the body when the lightbox is displaying using the handleScroll
function.
Tip 🔥
One other thing we want to prevent is layering of the same component. For example, clicking on the main image container in the lightbox should not result to another layer of a lightbox instance added on top of the existing. This whatLightboxContext
helps make possible!
Lastly, the LightBox component is a Modal which I define as one of the reusable UI components. I won’t go into detail about how to create a modal that’s ported to maintain semantic HTML hierarchy in this post as we’re kinda modal pros here with the number of times we’ve built one.
If you want the full details on building a reusable React modal component with React portals, head over to How To Work With React Fragments, Portals, And Refs (Part 6).
Product information
The product information section has two parts: the first displays data about the product while the second involves cart actions.
Starting with the data, I structured mine in a helpers
directory to simulate a more robust application with real data.
export const PRODUCTS = [
{
id: Math.floor(Math.random() * 10),
brand: 'Sneaker Company',
name: 'Fall Limited Edition Sneakers',
description: 'These low-profile sneakers are your perfect casual wear companion.Featuring a durable rubber outter sole, they\\'ll withstand everything the weather can offer.',
currentPrice: 125,
fullPrice: 250,
sale: 0.5,
img: {
thumb: prod_1_thumb
}
}
]
Then I use this in ProductInfo
to serve the client.
export default function ProductInfo() {
const [displayProduct, setDisplayProduct] = useState(PRODUCTS[0]); // Dummy data and handling, assuming indicator of single product is available
const { ...item } = displayProduct;
return (
<div className={s.productInfo}>
<span className={s.company}>{item.brand}</span>
<hgroup className={s.product}>
<h2>{item.name}</h2>
<p>{item.description}</p>
</hgroup>
<ProductPricing item={item} />
<CartAction item={displayProduct} />
</div>
);
}
Moving on to the cart action, I’ll break the action into two parts since we have the increment/decrement action that determines the quantity of a product to add to the cart and the button that performs the add-to-cart action.
export default function CartAction({item}) {
const [incDefault, setIncDefault] = useState(0);
const [incAction, setIncAction] = useState(null);
return (
<div className={s.cartActionWrapper}>
<CartIncrement inc={incDefault} onInc={setIncDefault} onIncAct={setIncAction} />
<AddToCart item={item} quantity={incDefault} action={incAction} />
</div>
);
}
I’m managing two states: incDefault
that determines the quantity of product and incAction
that’s a flag of sorts for determining the reducer action in the cart context.
export default function CartIncrement({inc, onInc, onIncAct}) {
const decHandler = () => {
onInc(prevState => prevState > 0 ? prevState - 1 : 0);
onIncAct('dec');
};
const incHandler = () => {
onInc(prevState => prevState + 1);
onIncAct('inc');
};
return (
<div className={s.cartIncrementWrapper}>
<button className={`${s.incBtn} noBtn`} onClick={decHandler}><img src="/static/icon-minus.svg" alt="" /></button>
<span className={s.cartQt}>{inc}</span>
<button className={`${s.incBtn} noBtn`} onClick={incHandler}><img src="/static/icon-plus.svg" alt="" /></button>
</div>
)
}
The click action on the plus and minus icons mainly sets different states. We use these states in the AddToCart
component to manage the items in the cart.
export default function AddToCart({item, quantity, action}) {
const {items, addItem, removeItem} = useMyContext(CartContext);
const addToCartHandler = () => {
if(quantity === 0) return;
const newItem = {...item, qt: quantity};
if(action === 'inc') {
addItem(newItem);
} else if(action === 'dec') {
if(items.length > 0) {
removeItem({item: newItem, subtype: 'dec'});
} else {
addItem(newItem);
}
}
}
return (
<button className={`noBtn ${s.addToCartBtn}`} onClick={addToCartHandler}>
<span className={s.cartIcon}>
<CartIcon fill={`#fff`} />
</span>
<span>Add to cart</span>
</button>
)
}
For more details on creating a cart context and its respective provider with a reducer-managed state, see How To Make A Really Good Shopping Cart With React.
Deploying a Vite static page to GitHub pages
With the project complete, the last step is to deploy to a hosted platform to share (and submit) the URL 🙌
Similarly to how we’ve deployed other React projects to GitHub pages, I’ll use the gh-pages package.
Hey!
For an example of using the gh-pages package to deploy a React application to GitHub pages checkout out How To Build A Wicked Cool App With React + Flask.
In addition, to deploy the static site to GitHub pages with Vite, we’ll need to set a base public path in the Vite config file. Why? Because the project will live in the repo not in the root GitHub URL, it’s technically “nested”.
The updated config looks like so:
export default defineConfig({
plugins: [react()],
base: process.env.NODE_ENV === 'production' ? `/frontendmentor_ecommerce_page/`: `/`
})
Tip 👇
Deploying to a prefixed path requires extra attention in the manner of referencing asset paths throughout the application! The “how” varies across different frameworks and libraries.
We’re not done yet. Since the deployed application will live under a prefixed path (aka the repo on our GitHub account), we need to modify the asset paths accordingly.
According to the Vite documentation, “JS-imported asset URLs, CSS url()
references, and asset references in your .html
files are all automatically adjusted to respect this option during build.”
Luckily, I imported all assets except the plus and minus icons on the CartIncrement
component. Here, I’ll need to prefix the direct path reference which I do by creating a helper function (again, assuming this is a more robust application).
// src/helpers/utils.js
const BASE_URL = import.meta.env.BASE_URL;
export const getAssetPath = (path) => `${BASE_URL}${path}`;
In this way, any direct asset path sources will be prefixed accordingly (recall the base path in the config file is set conditional to the env).
Of course, you could import the darn things and let Vite handle the paths internally 😅 But I wanted to show you how to handle cases where you reference asset paths via the source.
Wrapping up
With the project deployed, we’ve finalized Frontend Mentor’s e-commerce product page with React and Vite.
This tutorial explored essential concepts such as component reusability, state management, and custom hooks.
We’ve built a lightbox product gallery and implemented an image slider with arrow navigation for smaller screens!
We’re on the roll 😏
Don’t stop here—I’d take this to the next level by adding basic routing to incorporate the cart page. Or take it a step up and use one of many practice data APIs to query and handle multiple products.
There are many ways to build on the foundational skills this basic product equipped us with to create real-world projects that deliver exceptional user experiences. So go ahead, unleash your creativity!
All source code for this project is available on GitHub.
Ok, bye now 👋