How to Build an E-commerce Product Page with React and Vite

by kleamerkuri
Ecommerce product page Frontend Mentor.

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 compelted 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 whether ProductDisplay is part of the lightbox component to prevent triggering the lightbox display
  • The ArrowNav component uses currentIndex to keep track of which item is selected for display
  • The ProductThumbnail component controls the display of the product thumbnails and syncs the selectedItem
  • selectedItem manages the state of the displayed product which gets updated by ArrowNav and ProductThumbnail respectively

Note šŸ‘€
Iā€™m simulating structured data by utilizing Array.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.

Product page lightbox nav.

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 what LightboxContext 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 šŸ‘‹

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.

Related Posts