To know more, visit udemy react course from John - video # 437 - Formspree. He uses this kind of service for his website to give monthly promotions through email
3. How to format price
Intl function handles all the currency conversion for us. Just pass the cents and it gives you back dollars with proper currency symbol $ at the beginning. For more info refer #13.-format-price
exportconstformatPrice= (number) => {constformattedNumber=newIntl.NumberFormat('en-US', { style:'currency', currency:'USD', }).format(number /100) // note that we still need to divide by 100 but don't have to format the decimalsreturn formattedNumber // it automatically adds the currency representation (Adds $ at the beginning)}exportconstgetUniqueValues= () => {}
4. useHistory hook
To programmatically navigate back to some page, we can use this hook. Refer to #15.-single-product-functionality-part-2 that shows the usage of useHistory. An alternative approach to this is
Later we will use this too in our app when using auth0 and converting our project to react-router-6
🏄♀️ Flow of the app
For each step, if we are modifying any files I will mention that at the beginning of that step (blue color hint). Green color hint button will be sometimes used at the end of some step to describe what functionality should be working at that particular step. If there is any complex things that happens in any step we will add that to Things we can learn section above and I will explain that in detail
commit ID - a67783a5c95c19a5ba74c50643c4d8130e672f55
2. React router - Add all routes
25_ecommerce_app/src/pages/index.js
25_ecommerce_app/src/App.js
Let's add react router to display different routes like Home page, About page, Products page, Single Product page, Cart, Checkout. We will also use Pages/index.js to import all Pages into index page as usual.
Just to brush your memory, this is how we add routes in react-router-5. Taking an example here to show products
{/* SingleProduct -> This would be little different as it would have a children prop*/}<Routeexactpath="/products/:id"children={<SingleProduct />} />
We need to have Navbar, Sidebar and Footer in all the pages so we add it like this. Technically, we can place Sidebar in any order(as it is position fixed) but let's just put it after Navbar
return ( <Router> {/* Display Navbar & Sidebar in all pages, so it's placed outside of <Switch/> */} <Navbar /> <Sidebar /> {/*Sidebar is position fixed so can be put in any order*/} <Switch> {/* Home */} <Routeexactpath="/"> <Home /> </Route> {/* About */} <Routeexactpath="/about"> <About /> </Route> {/* Cart */} <Routeexactpath="/cart"> <Cart /> </Route> {/* Products */} <Routeexactpath="/products"> <Products /> </Route> {/* SingleProduct -> This would be little different as it would have a children prop*/} <Routeexactpath="/products/:id"children={<SingleProduct />} /> {/* Checkout -> This would be wrapped in PrivateRoute later */} <Routeexactpath="/checkout"> <Checkout /> </Route> {/* Error */} <Routeexactpath="*"> <Error /> </Route> </Switch> {/* Display footer in all pages, so it's placed outside of <Switch/> */} <Footer /> </Router> )
You can test the app at this point. Navigate to /products, /products/xyz, /somethingelse and it should show proper pages with headers and footers in each page (Just the text in each page)
import React from'react'import { BrowserRouter as Router, Switch, Route } from'react-router-dom'import { Navbar, Sidebar, Footer } from'./components'import { About, AuthWrapper, Cart, Checkout, Error, Home, PrivateRoute, Products, SingleProduct,} from'./pages'functionApp() {return ( <Router> {/* Display Navbar & Sidebar in all pages, so it's placed outside of <Switch/> */} <Navbar /> <Sidebar /> {/*Sidebar is position fixed so can be put in any order*/} <Switch> {/* Home */} <Routeexactpath="/"> <Home /> </Route> {/* About */} <Routeexactpath="/about"> <About /> </Route> {/* Cart */} <Routeexactpath="/cart"> <Cart /> </Route> {/* Products */} <Routeexactpath="/products"> <Products /> </Route> {/* SingleProduct -> This would be little different as it would have a children prop*/} <Routeexactpath="/products/:id"children={<SingleProduct />} /> {/* Checkout -> This would be wrapped in PrivateRoute later */} <Routeexactpath="/checkout"> <Checkout /> </Route> {/* Error */} <Routeexactpath="*"> <Error /> </Route> </Switch> {/* Display footer in all pages, so it's placed outside of <Switch/> */} <Footer /> </Router> )}exportdefault App
3. Navbar setup
25_ecommerce_app/src/components/Navbar.js
Let's now setup Navbar
In Navbar, one thing to notice is the logo image we are getting from assets. We import like this
For styles, we have this min-width just for you to remember
// nav-toggle will be hidden on small screen and will be display only if we have a minimum of 992px wide screen @media (min-width: 992px) { .nav-toggle { display: none; }
The app looks like this now in big and small screens
Full code looks like this
components/Navbar.js
import React from'react'import styled from'styled-components'import logo from'../assets/logo.svg'import { FaBars } from'react-icons/fa'import { Link } from'react-router-dom'import { links } from'../utils/constants'import CartButtons from'./CartButtons'import { useProductsContext } from'../context/products_context'import { useUserContext } from'../context/user_context'constNav= () => {return ( <NavContainer> <divclassName="nav-center"> <divclassName="nav-header"> <Linkto="/"> <imgsrc={logo} alt="comfy sloth" /> </Link> <buttontype="button"className="nav-toggle"> <FaBars /> </button> </div> {/* nav-links will also be re-used in Sidebar */} <ulclassName="nav-links"> {links.map((link) => {const { id,text,url } = linkreturn ( <likey={id}> <Linkto={url}>{text}</Link> </li> ) })} </ul> {/* We will add CartButtons here shortly */} </div> </NavContainer> )}constNavContainer=styled.nav` height: 5rem; display: flex; align-items: center; justify-content: center; .nav-center { width: 90vw; margin: 0 auto; max-width: var(--max-width); } .nav-header { display: flex; align-items: center; justify-content: space-between; img { width: 175px; margin-left: -15px; } } .nav-toggle { background: transparent; border: transparent; color: var(--clr-primary-5); cursor: pointer; svg { font-size: 2rem; } } .nav-links { display: none; } .cart-btn-wrapper { display: none; } // nav-toggle will be hidden on small screen and will be display only if we have a minimum of 992px wide screen @media (min-width: 992px) { .nav-toggle { display: none; } .nav-center { display: grid; grid-template-columns: auto 1fr auto; align-items: center; } .nav-links { display: flex; justify-content: center; li { margin: 0 0.5rem; } a { color: var(--clr-grey-3); font-size: 1rem; text-transform: capitalize; letter-spacing: var(--spacing); padding: 0.5rem; &:hover { border-bottom: 2px solid var(--clr-primary-7); } } } .cart-btn-wrapper { display: grid; } }`exportdefault Nav
3. Navbar - Cart buttons (not functionality yet)
25_ecommerce_app/src/components/Navbar.js
25_ecommerce_app/src/components/CartButtons.js
In previous step, we left off with adding cart buttons to Navbar. Let's add them now.
We are just adding Cart button Link and Logout button for now. No functionality yet.
Full code
/components/Navbar.js
import React from'react'import styled from'styled-components'import logo from'../assets/logo.svg'import { FaBars } from'react-icons/fa'import { Link } from'react-router-dom'import { links } from'../utils/constants'import CartButtons from'./CartButtons'import { useProductsContext } from'../context/products_context'import { useUserContext } from'../context/user_context'constNav= () => {return ( <NavContainer> <divclassName="nav-center"> <divclassName="nav-header"> <Linkto="/"> <imgsrc={logo} alt="comfy sloth" /> </Link> <buttontype="button"className="nav-toggle"> <FaBars /> </button> </div> {/* nav-links will also be re-used in Sidebar */} <ulclassName="nav-links"> {links.map((link) => {const { id,text,url } = linkreturn ( <likey={id}> <Linkto={url}>{text}</Link> </li> ) })} </ul> <CartButtons /> </div> </NavContainer> )}constNavContainer=styled.nav` height: 5rem; display: flex; align-items: center; justify-content: center; .nav-center { width: 90vw; margin: 0 auto; max-width: var(--max-width); } .nav-header { display: flex; align-items: center; justify-content: space-between; img { width: 175px; margin-left: -15px; } } .nav-toggle { background: transparent; border: transparent; color: var(--clr-primary-5); cursor: pointer; svg { font-size: 2rem; } } .nav-links { display: none; } .cart-btn-wrapper { display: none; } // nav-toggle will be hidden on small screen and will be display only if we have a minimum of 992px wide screen @media (min-width: 992px) { .nav-toggle { display: none; } .nav-center { display: grid; grid-template-columns: auto 1fr auto; align-items: center; } .nav-links { display: flex; justify-content: center; li { margin: 0 0.5rem; } a { color: var(--clr-grey-3); font-size: 1rem; text-transform: capitalize; letter-spacing: var(--spacing); padding: 0.5rem; &:hover { border-bottom: 2px solid var(--clr-primary-7); } } } .cart-btn-wrapper { display: grid; } }`exportdefault Nav
Let's now design the sidebar. Checkout and cart button will be conditionally shown (Even logout).
For toggling the sidebar we always do this. We add have one class with z-index -1 and we translate to push it to left so it wont be shown. Then to show we have a class and to hide we can remove that class.
.sidebar { position: fixed; top:0; left:0; width:100%; height:100%; background:var(--clr-white); transition:var(--transition); transform:translate(-100%); z-index:-1; }// show-sidebar class is responsible for toggling the sidebar .show-sidebar { transform:translate(0); z-index:999; }
6. Sidebar toggle functionality implemented in Product context
25_ecommerce_app/src/context/products_context.js - Define open and close functions for sidebar
25_ecommerce_app/src/reducers/products_reducer.js - Define action handlers for the above functions in context
25_ecommerce_app/src/index.js - Wrap the app inside the above product context
25_ecommerce_app/src/components/Sidebar.js - Invoke the function to close sidebar from products_context
25_ecommerce_app/src/components/CartButtons.js - Invoke the function to close sidebar from products_context
25_ecommerce_app/src/components/Navbar.js - Invoke the function to open sidebar from products_context
In our previous step 5, we did sidebar. The things to note now is,
The button we click to close the sidebar will be on sidebar
Note that the button we click to open the sidebar will be on theNavbar. Hence we need contact from Navbar to sidebar. So we could,
Either define a state in App.js and define sidebar open and close functionality and then pass this as prop to sidebar and navbar. The problem here is prop drilling and also app.js grows quite big very fast
Alternatively in a cleaner way, we could define a context to do this which we will do it now in the product context. Each context will have contact with it's reducer so the dispatched action from Product context will be inside Products reducer.
Why are we adding the sidebar open and close functionality in productContext? Didn't we find a better place to add this?
Well honestly we could have created one more context just to handle this functionality, but I feel, since productContext is a bit smaller whose primary responsibility is to get products, we can just add the sidebar functionality here.
Since we are now working in Product Context, in order to access the values from this context we need to wrap our app in the ProductProvider, so let's do that first
import React from'react'import styled from'styled-components'import { PageHero } from'../components'import aboutImg from'../assets/hero-bcg.jpeg'constAboutPage= () => {return ( <main> <PageHerotitle="about" /> {/* The classes here is as follows defined in index.css - page - min-height: calc(100vh - (20vh + 10rem)); -> to give a fixed height - section - padding: 5rem 0; -> to give little top and bottom padding - section-center -> to center the content on the page */} {/* wrapper is a two column layout here defined using the Grid in Wrapper */} <WrapperclassName="page section section-center"> <imgsrc={aboutImg} alt="Nice desk" /> <article> <divclassName="title"> <h2>our story</h2> <divclassName="underline"></div> </div> <p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Adipisci, veniam non cumque velit doloribus expedita culpa mollitia maiores unde harum sed incidunt laboriosam animi explicabo repellat commodi iste quasi quidem. </p> </article> </Wrapper> </main> )}
pages/Checkout.js
import React from'react'import styled from'styled-components'import { PageHero, StripeCheckout } from'../components'// extra importsimport { useCartContext } from'../context/cart_context'import { Link } from'react-router-dom'constCheckoutPage= () => {return ( <main> <PageHerotitle="checkout" /> <WrapperclassName="page"> <h3>Checkout here (Will work on this later)</h3> </Wrapper> </main> )}constWrapper=styled.div``exportdefault CheckoutPage
9. Home page
25_ecommerce_app/src/pages/HomePage.js
Below components are used inside HomePage above
25_ecommerce_app/src/components/Hero.js
25_ecommerce_app/src/components/FeaturedProducts.js - Not touching this now (just adding it here so you know that this belongs to Home page and needs to be modified later)
25_ecommerce_app/src/components/Services.js
25_ecommerce_app/src/components/Contact.js
Let's now design Home page.
Note that: We will skip the Featured Products section for now in Home Page and we will come back to this later. You don't need to worry about this now.
One thing to note here for Services section. It basically looks like this in home page
Even though we can hardcode these icons and text here (marked in blue arrows in the above image), it would still be better to get the icons and text from a file called /utils/constants. The {services} file looks like this
import React from'react'import { GiCompass, GiDiamondHard, GiStabbedNote } from'react-icons/gi'exportconstlinks= [ { id:1, text:'home', url:'/', }, { id:2, text:'about', url:'/about', }, { id:3, text:'products', url:'/products', },]exportconstservices= [ { id:1, icon: <GiCompass />, title:'mission', text:'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Voluptates, ea. Perferendis corrupti reiciendis nesciunt rerum velit autem unde numquam nisi', }, { id:2, icon: <GiDiamondHard />, title:'vision', text:'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Voluptates, ea. Perferendis corrupti reiciendis nesciunt rerum velit autem unde numquam nisi', }, { id:3, icon: <GiStabbedNote />, title:'history', text:'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Voluptates, ea. Perferendis corrupti reiciendis nesciunt rerum velit autem unde numquam nisi', },]exportconstproducts_url='https://course-api.com/react-store-products'exportconstsingle_product_url=`https://course-api.com/react-store-single-product?id=`
Note that in in export const services, in each item we have an icon that is imported from react icons. In order for the icons to be placed here, we need to import react. For export const links, we didn't need this as we are not using any jsx like we do for icons in services. This is just something for you to keep in mind while using jsx in non component file. By non-component file, I mean the file which is not a react component. So the rule of thumb is, if you are using jsx in any file even a small bit like this icon then you need to import react.
Full code
components/Hero.js
import React from'react'import styled from'styled-components'import { Link } from'react-router-dom'import heroBcg from'../assets/hero-bcg.jpeg'import heroBcg2 from'../assets/hero-bcg-2.jpeg'constHero= () => {return ( <WrapperclassName="section-center"> <articleclassName="content"> <h1> design your <br /> comfort zone </h1> <p> Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod molestias illo iste pariatur dignissimos, ab beatae magnam modi maiores doloremque quidem nostrum dolorem! </p> <Linkto="/products"className="btn hero-btn"> shop now </Link> </article> <articleclassName="img-container"> <imgsrc={heroBcg} alt="nice table"className="main-img" /> <imgsrc={heroBcg2} alt="person working"className="accent-img" /> </article> </Wrapper> )}
components/Services.js
import React from'react'import styled from'styled-components'import { services } from'../utils/constants'constServices= () => {return ( <Wrapper> <divclassName="section-center"> <articleclassName="header"> <h3> custom furniture <br /> built only for you{' '} </h3> <p> Lorem ipsum dolor sit, amet consectetur adipisicing elit. Nihil fugit fugiat ullam eligendi blanditiis. Ad veritatis suscipit, est nihil nisi eum! Maiores hic soluta. </p> </article> <divclassName="services-center"> {services.map((service) => {const { id,icon,title,text } = servicereturn ( <articlekey={id} className="service"> <spanclassName="icon">{icon}</span> <h4>{title}</h4> <p>{text}</p> </article> ) })} </div> </div> </Wrapper> )}
components/Contact.js
import React from'react'import styled from'styled-components'constContact= () => {return ( <Wrapper> <divclassName="section-center"> <h3>Join our news letter and get 20% off</h3> <divclassName="content"> <p> Lorem ipsum dolor sit amet consectetur adipisicing elit. Optio quis consequuntur ex in at alias labore quibusdam tenetur ab quia voluptatum impedit, minus temporibus dicta quidem corrupti vitae itaque, earum nihil qui! Quidem repellat eos ex ab dolorum facilis officiis doloremque unde nostrum magni, voluptas dolores iure perspiciatis. Magni, vitae. </p> <formclassName="contact-form"> <inputtype="text"className="form-input"placeholder="enter email" /> <buttonclassName="submit-btn">subscribe</button> </form> </div> </div> </Wrapper> )}
We will be using John's API built in Node course which even I have built, but still I want to use John's API to keep my app up and running always.
In the node course we built a full blown e-commerce api with some of these routes
CRUD - products - where admin could create, update and delete products. But read products was a public get method and no auth was required
CRUD - users
In our app now, we will only use the read functionality of products route which is open to public like any other API we used in our other projects. We have placed our products API URL in 25_ecommerce_app/src/utils/constants.js
Some of the products will have feature:true and other products that don't have this will be featured false. The featured products will be displayed on HomePage. All the products will be displayed on /products page like this. We will get the products and show them both in Home and Products page in products context.
Later we will filter, sort and everything, but for now in next step, let's just fetch the products in products context.
11. Fetch Products (And also set featured products)
25_ecommerce_app/src/context/products_context.js
25_ecommerce_app/src/reducers/products_reducer.js
context/products_context.js
// fetch productsconstfetchProducts=async (url) => {constresponse=awaitaxios.get(url)console.log(response.data) }// fetch the products once and show the featured ones to HomePage. Show all products in /products pageuseEffect(() => {fetchProducts(url) }, [])
It's good that we are able to fetch products. But we now want to handle loading, error and all that before displaying the products to UI. Let's do that now by adding more properties in iniitalState of products_context
Full code
context/products_context.js
import axios from'axios'import React, { useContext, useEffect, useReducer } from'react'import reducer from'../reducers/products_reducer'import { products_url as url } from'../utils/constants'import { SIDEBAR_OPEN, SIDEBAR_CLOSE, GET_PRODUCTS_BEGIN, GET_PRODUCTS_SUCCESS, GET_PRODUCTS_ERROR, GET_SINGLE_PRODUCT_BEGIN, GET_SINGLE_PRODUCT_SUCCESS, GET_SINGLE_PRODUCT_ERROR,} from'../actions'constinitialState= { isSidebarOpen:false, products_loading:false,// loading for all products. We will soon add loading for single product products_error:false, products: [],// this is shown on the products page featured_products: [],// this is shown on the home page// 3 more props to come next. loading, error and product props - for singleProduct}constProductsContext=React.createContext()exportconstProductsProvider= ({ children }) => {const [state,dispatch] =useReducer(reducer, initialState)constopenSidebar= () => {dispatch({ type:SIDEBAR_OPEN }) }constcloseSidebar= () => {dispatch({ type:SIDEBAR_CLOSE }) }// fetch productsconstfetchProducts=async (url) => {dispatch({ type:GET_PRODUCTS_BEGIN }) // this is to set the loading to truetry {constresponse=awaitaxios.get(url)constproducts=response.datadispatch({ type:GET_PRODUCTS_SUCCESS, payload: products }) // this is to set products array and also featured products and then loading to false } catch (error) {dispatch({ type:GET_PRODUCTS_ERROR }) // this sets loading to false and error to true for products } }// fetch the products once and show the featured ones to HomePage. Show all products in /products pageuseEffect(() => {fetchProducts(url) }, [])return ( <ProductsContext.Providervalue={{ ...state, openSidebar, closeSidebar }}> {children} </ProductsContext.Provider> )}// make sure useexportconstuseProductsContext= () => {returnuseContext(ProductsContext)}
Ok, now that we have displayed the featured products on home page, if you take a look at the image in previous step, we are showing numbers (cents) for price without any format. We need to work on that now.
You need to remember these things when working with price (dollars and cents) in javascript
When we look at payment processors like stripe, they will be looking for the smallest unit of currency (in cents). For example, if we are selling in dollars they will be looking that dollar converted into cents (smallest possible unit of that dollar)
The second reason is, in our app we will be building the cart. In that cart, we will have a bunch of calculations like converting in to decimals and so on. The problem with javascript is, during handling of decimals, once in a while we get some weird values. We don't want to have any kind of bug when it comes to real money.
So the takeaway is, always setup the amount in smallest possible unit. Technically we can just divide our cents and we get amount in dollars. But the problem is with decimals some times that like I said, JS will not work well in some cases for decimals. To deal with that we will setup a util function to make this formatting which converts cents (smallest unit of currency) to dollar representation. The advantage of this is, JS will provide internationalization option where we can also convert to different country formatting if we want to.
So let's do that util function now.
components/Product.js
<footer> <h5>{name}</h5> <p>{formatPrice(price)}</p> // formatPrice is the util function </footer>
Full code
utils/helper.js
exportconstformatPrice= (number) => {constformattedNumber=newIntl.NumberFormat('en-US', { style:'currency', currency:'USD', }).format(number /100) // note that we still need to divide by 100 but don't have to format the decimalsreturn formattedNumber // it automatically adds the currency representation (Adds $ at the beginning)}exportconstgetUniqueValues= () => {}
components/Product.js
import React from'react'import styled from'styled-components'import { formatPrice } from'../utils/helpers'import { FaSearch } from'react-icons/fa'import { Link } from'react-router-dom'constProduct= ({ image, name, price, id }) => {return ( <Wrapper> <divclassName="container"> <imgsrc={image} alt={name} /> <Linkto={`/products/${id}`} className="link"> <FaSearch /> </Link> </div> <footer> <h5>{name}</h5> {/* formatPrice() function gives back the currency symbol as well so we don't have to place it here */} <p>{formatPrice(price)}</p> </footer> </Wrapper> )}
14. Single Product Functionality Part 1
25_ecommerce_app/src/context/products_context.js
25_ecommerce_app/src/reducers/products_reducer.js
If we click on a Product link on one of Featured product in home page it opens a single product page and it looks like this currently
We will implement the fetch functionality for single product in products context like we did while fetching the products. One difference is, we called the fetchProducts() inside useEffect of products_context, but we will call fetchSingleProduct inside useEffect of SingleProduct component as there is no need to fetch it until we reach SingleProduct page.
In this step we will write code for context and reducer. In next part we will then call that fetchSingleProduct function inside the SingleProduct component
// fetch single productconstfetchSingleProduct=async (url) => {dispatch({ type:GET_SINGLE_PRODUCT_BEGIN }) // this is to set the loading to true for single producttry {constresponse=awaitaxios.get(url)constsingleProduct=response.datadispatch({ type:GET_SINGLE_PRODUCT_SUCCESS, payload: singleProduct }) } catch (error) {dispatch({ type:GET_SINGLE_PRODUCT_ERROR }) } }
Full code
context/products_context.js
import axios from'axios'import React, { useContext, useEffect, useReducer } from'react'import reducer from'../reducers/products_reducer'import { products_url as url } from'../utils/constants'import { SIDEBAR_OPEN, SIDEBAR_CLOSE, GET_PRODUCTS_BEGIN, GET_PRODUCTS_SUCCESS, GET_PRODUCTS_ERROR, GET_SINGLE_PRODUCT_BEGIN, GET_SINGLE_PRODUCT_SUCCESS, GET_SINGLE_PRODUCT_ERROR,} from'../actions'constinitialState= { isSidebarOpen:false, products_loading:false,// loading for all products. products_error:false, products: [],// this is shown on the products page featured_products: [],// this is shown on the home page// for singleProduct single_product_loading:false, single_product_error:false, single_product: {},}constProductsContext=React.createContext()exportconstProductsProvider= ({ children }) => {const [state,dispatch] =useReducer(reducer, initialState)constopenSidebar= () => {dispatch({ type:SIDEBAR_OPEN }) }constcloseSidebar= () => {dispatch({ type:SIDEBAR_CLOSE }) }// fetch productsconstfetchProducts=async (url) => {dispatch({ type:GET_PRODUCTS_BEGIN }) // this is to set the loading to truetry {constresponse=awaitaxios.get(url)constproducts=response.datadispatch({ type:GET_PRODUCTS_SUCCESS, payload: products }) // this is to set products array and also featured products and then loading to false } catch (error) {dispatch({ type:GET_PRODUCTS_ERROR }) // this sets loading to false and error to true for products } }// fetch the products once and show the featured ones to HomePage. Show all products in /products pageuseEffect(() => {fetchProducts(url) }, [])// fetch single product - using useCallback to avoid creating fetchSingleProduct everytime// which is optionalconstfetchSingleProduct=React.useCallback(async (url) => {dispatch({ type:GET_SINGLE_PRODUCT_BEGIN }) // this is to set the loading to true for single producttry {constresponse=awaitaxios.get(url)constsingleProduct=response.datadispatch({ type:GET_SINGLE_PRODUCT_SUCCESS, payload: singleProduct }) } catch (error) {dispatch({ type:GET_SINGLE_PRODUCT_ERROR }) } }, [])return ( <ProductsContext.Providervalue={{ ...state, openSidebar, closeSidebar, fetchSingleProduct }} > {children} </ProductsContext.Provider> )}// make sure useexportconstuseProductsContext= () => {returnuseContext(ProductsContext)}
reducers/products_reducer.js
import { SIDEBAR_OPEN, SIDEBAR_CLOSE, GET_PRODUCTS_BEGIN, GET_PRODUCTS_SUCCESS, GET_PRODUCTS_ERROR, GET_SINGLE_PRODUCT_BEGIN, GET_SINGLE_PRODUCT_SUCCESS, GET_SINGLE_PRODUCT_ERROR,} from'../actions'constproducts_reducer= (state, action) => {if (action.type ===SIDEBAR_OPEN) {return { ...state, isSidebarOpen:true } }if (action.type ===SIDEBAR_CLOSE) {return { ...state, isSidebarOpen:false } }// products and featured productsif (action.type ===GET_PRODUCTS_BEGIN) {return { ...state, products_loading:true, products_error:false } // setting up products error to be false just in case if it was true last time }if (action.type ===GET_PRODUCTS_SUCCESS) {constproducts=action.payloadconstfeatured_products=products.filter((product) =>product.featured)return {...state, products_loading:false, products_error:false, products, featured_products, } }if (action.type ===GET_PRODUCTS_ERROR) {return { ...state, products_loading:false, products_error:true } }// single productif (action.type ===GET_SINGLE_PRODUCT_BEGIN) {return {...state, single_product_loading:true, single_product_error:false,// setting up products error to be false just in case if it was true last time } }if (action.type ===GET_SINGLE_PRODUCT_SUCCESS) {return {...state, single_product_loading:false, single_product_error:false, single_product:action.payload, } }if (action.type ===GET_SINGLE_PRODUCT_ERROR) {return {...state, single_product_loading:false, single_product_error:true, } }thrownewError(`No Matching "${action.type}" - action type`)}exportdefault products_reducer
15. Single Product Functionality Part 2
25_ecommerce_app/src/pages/SingleProductPage.js
All the below components are in SingleProductPage above
25_ecommerce_app/src/components/PageHero.js
25_ecommerce_app/src/components/ProductImages.js
25_ecommerce_app/src/components/Stars.js
Since SingleProductPage is big and has lot of parts, let's divide SingleProductPage into multiple components like this and get these components into SingleProductPage as shown below. Also we need to work a little bit to change Page hero as it has an extra text to display like this -> Home / Products / Suede Armchair
In App.js we have this code to display SingleProduct
<Routeexactpath="/products/:id"children={<SingleProduct />} />// this :id is what we can get from useParams hook inside singleProduct page
constSingleProductPage= () => {const { fetchSingleProduct } =useProductsContext()const { id } =useParams()console.log('The id is', id) // this is that id defined in App.js for singleProduct
After adding the useEffect inside SingleProduct to fetch single product it looks this way
When we click on any product (click on one of the feature products on home page) console log in line 15 gives this
Now we will also need to check for loading and error and handle them inside SingleProduct before we show single product on screen. It looks like this after adding loading and error
Now change the single_product_url and it should show you the error like this
Now we can leave it at this, but a better user experience would be to automatically navigate to home screen after 3 seconds if there is an error. Let's do it using another useEffect
// we will navigate back to home screen after 3 sec if there // is an error in fetching single product// To test this, change single product url and you should see this behaviouruseEffect(() => {if (error) {setTimeout(() => {history.push('/') },3000) } }, [error, history])
Our code currently looks this way
constSingleProductPage= () => {const {fetchSingleProduct, single_product_loading: loading, single_product_error: error, single_product: product, } =useProductsContext()const { id } =useParams()consthistory=useHistory()useEffect(() => {fetchSingleProduct(`${url}${id}`) }, [fetchSingleProduct, id])// we will navigate back to home screen after 3 sec if there is an error in fetching single product// To test this, change single product url and you should see this behaviouruseEffect(() => {if (error) {setTimeout(() => {history.push('/') },3000) } }, [error, history])if (loading) {return <Loading /> }if (error) {return <Error /> }const { id: sku,stock,price,shipping,featured,colors,category,images,reviews,stars,name,description,company, } = productconsole.log(product)return ( <Wrapper> <PageHerotitle={name} product /> <divclassName="section section-center page"> <Linkto="/products"className="btn"> Back to products </Link> <divclassName="product-center"> <ProductImages /> <sectionclassName="content"> <h2>{name}</h2> <Stars /> <h5className="price">{formatPrice(price)}</h5> <pclassName="desc">{description}</p> <pclassName="info"> <span>Available : </span> {stock >0?'In stock':'Out of stock'} </p> <pclassName="info"> <span>SKU : </span> {sku} </p> <pclassName="info"> <span>Brand : </span> {company} </p> <hr /> {stock >0&& <AddToCart />} </section> </div> </div> </Wrapper> )}
And the singleProduct page (due to code above) looks like this
Next, let's work on Product images. Before you code Product Images, let me show you the change we made on PageHero
After adding conditional code to display hero on single product page (which is different on other pages)
components/PageHero.js
import React from'react'import styled from'styled-components'import { Link } from'react-router-dom'// the page hero for single product will be a bit different./* For Products ----> Home / Products For Single Product -----> Home / Products / Chair*/// to achieve the above let's do a conditional logic using product prop. // If it exists then it is for single productconstPageHero= ({ title, product }) => {return ( <Wrapper> <divclassName="section-center"> <h3> <Linkto="/">Home </Link>/{' '} {product && <Linkto="/products">Products /</Link>} {title} </h3> </div> </Wrapper> )}
Product Images
Let's now work on Product Images part.
We will pass the images to <ProductImages/> and then use s simple useState to show current selected image (no need to use context since it is local to ProductImage component)
import React, { useState } from'react'import styled from'styled-components'constProductImages= ({ images = [{ url:'' }] }) => {// initially images will be empty so setting a default here to [] and adding first prop with url = '' as we are using it belowconst [mainImage,setMainImage] =useState(images[0])return ( <Wrapper> <imgsrc={mainImage.url} alt="main" /> <divclassName="gallery"> {images.map((image, index) => {return ( <imgonClick={() =>setMainImage(images[index])}key={index}src={image.url}alt={image.filename}className={`${image.url ===mainImage.url ?'active':null}`} // to set the active class on image to show which one is the main image currently /> ) })} </div> </Wrapper> )}
Stars
Let's now do Stars component. We will first take manual approach. Then later take Programmatic approach.
Stars - Manual Approach
Here we set each star like this. So we set this five times.
The above is a single star
Let's add five stars the same way
<Wrapper> <divclassName="stars"> {/* first star start */} <span> {stars >=1? ( <BsStarFill /> ) : stars >=0.5? ( <BsStarHalf /> ) : ( <BsStar /> )} </span> {/* first star end */} {/* second star start */} <span> {stars >=2? ( <BsStarFill /> ) : stars >=1.5? ( <BsStarHalf /> ) : ( <BsStar /> )} </span> {/* second star end */} {/* third star start */} <span> {stars >=3? ( <BsStarFill /> ) : stars >=2.5? ( <BsStarHalf /> ) : ( <BsStar /> )} </span> {/* third star end */} {/* fourth star start */} <span> {stars >=4? ( <BsStarFill /> ) : stars >=3.5? ( <BsStarHalf /> ) : ( <BsStar /> )} </span> {/* fourth star end */} {/* fifth star start */} <span> {stars ===5? ( <BsStarFill /> ) : stars >=4.5? ( <BsStarHalf /> ) : ( <BsStar /> )} </span> {/* fifth star end */} </div> <divclassName="reviews">({reviews} reviews) </div> </Wrapper>
Stars - Programmatic Approach
Above, we took an approach of repeating the same code five times for five stars. Let's now take programmatic approach.
We have to do this cart functionality where we need to have
Colors for the products
Add / Remove from cart
And while adding to cart we also need to check if those many items exist in the cart
Color
After adding color selection, this is how it looks
At this point after adding colors the AddToCart looks like this
components/AddToCart.js
import React, { useState } from'react'import styled from'styled-components'import { Link } from'react-router-dom'import { FaCheck } from'react-icons/fa'import { useCartContext } from'../context/cart_context'import AmountButtons from'./AmountButtons'constAddToCart= ({ product }) => {const { id,stock,colors } = product// state values to display selected color of the product. We can keep the state here as it is local to the product and don't need to bring it from the contextconst [mainColor,setMainColor] =useState(colors[0]) // setting first color as main colorreturn ( <Wrapper> <divclassName="colors"> <span>colors : </span> <div> {colors.map((color, index) => {return ( <buttonkey={index}// adding active class on selected button. All buttons by default have 0.5 opacity, but active class has opacity of 1. Check css below in this fileclassName={`color-btn ${mainColor === color &&'active'}`}style={{ backgroundColor: color }}onClick={() =>setMainColor(color)} > {mainColor === color && <FaCheck />} </button> ) })} </div> </div> <divclassName="btn-container"></div> </Wrapper> )}
Cart Buttons
Let's now add cart increase and decrease functionality
// Since cart increase and decrease are local to this component, AddToCart,// let's add handler functions here and pass them one level down into AmountButtons componentconstincrease= () => {setAmount((oldAmount) => {let tempAmount = oldAmount +1// check if amount increased is in stock or notif (tempAmount > stock) { tempAmount = stock }return tempAmount }) }constdecrease= () => {setAmount((oldAmount) => {let tempAmount = oldAmount -1// check if amount increased is in stock or notif (tempAmount <1) { tempAmount =1 }return tempAmount }) }
Full Code
components/AddToCart.js
import React, { useState } from'react'import styled from'styled-components'import { Link } from'react-router-dom'import { FaCheck } from'react-icons/fa'import { useCartContext } from'../context/cart_context'import AmountButtons from'./AmountButtons'constAddToCart= ({ product }) => {const { id,stock,colors } = product// state values to display selected color of the product. We can keep the state here as it is local to the product and don't need to bring it from the contextconst [mainColor,setMainColor] =useState(colors[0]) // setting first color as main color// state to keep track of selected number of items in cartconst [amount,setAmount] =useState(1) // By default no sense in having 0 items so let's do 1// Since cart increase and decrease are local to this component, let's add handler functions here and pass them one level down into AmountButtons componentconstincrease= () => {setAmount((oldAmount) => {let tempAmount = oldAmount +1// check if amount increased is in stock or notif (tempAmount > stock) { tempAmount = stock }return tempAmount }) }constdecrease= () => {setAmount((oldAmount) => {let tempAmount = oldAmount -1// check if amount increased is in stock or notif (tempAmount <1) { tempAmount =1 }return tempAmount }) }return ( <Wrapper> <divclassName="colors"> <span>colors : </span> <div> {colors.map((color, index) => {return ( <buttonkey={index}// adding active class on selected button. All buttons by default have 0.5 opacity, but active class has opacity of 1. Check css below in this fileclassName={`color-btn ${mainColor === color &&'active'}`}style={{ backgroundColor: color }}onClick={() =>setMainColor(color)} > {mainColor === color && <FaCheck />} </button> ) })} </div> </div> <divclassName="btn-container"> <AmountButtonsamount={amount}increase={increase}decrease={decrease} /> <Linkto="/cart"className="btn"> add to cart </Link> </div> </Wrapper> )}
import React, { useEffect } from'react'import { useParams, useHistory } from'react-router-dom'import { useProductsContext } from'../context/products_context'import { single_product_url as url } from'../utils/constants'import { formatPrice } from'../utils/helpers'import { Loading, Error, ProductImages, AddToCart, Stars, PageHero,} from'../components'import styled from'styled-components'import { Link } from'react-router-dom'import { useCallback } from'react'constSingleProductPage= () => {const {fetchSingleProduct, single_product_loading: loading, single_product_error: error, single_product: product, } =useProductsContext()const { id } =useParams()consthistory=useHistory()useEffect(() => {fetchSingleProduct(`${url}${id}`) }, [fetchSingleProduct, id])// we will navigate back to home screen after 3 sec if there is an error in fetching single product// To test this, change single product url and you should see this behaviouruseEffect(() => {if (error) {setTimeout(() => {history.push('/') },3000) } }, [error, history])if (loading) {return <Loading /> }if (error) {return <Error /> }const { id: sku,stock,price,shipping,featured,colors,category,images,reviews,stars,name,description,company, } = productconsole.log(product)return ( <Wrapper> <PageHerotitle={name} product /> <divclassName="section section-center page"> <Linkto="/products"className="btn"> Back to products </Link> <divclassName="product-center"> <ProductImagesimages={images} /> <sectionclassName="content"> <h2>{name}</h2> <Starsstars={stars} reviews={reviews} /> <h5className="price">{formatPrice(price)}</h5> <pclassName="desc">{description}</p> <pclassName="info"> <span>Available : </span> {stock >0?'In stock':'Out of stock'} </p> <pclassName="info"> <span>SKU : </span> {sku} </p> <pclassName="info"> <span>Brand : </span> {company} </p> <hr /> {stock >0&& <AddToCartproduct={product} />} </section> </div> </div> </Wrapper> )}
The SingleProductPage looks this way till now
17. Filter context in Product Page
25_ecommerce_app/src/context/filter_context.js
25_ecommerce_app/src/reducers/filter_reducer.js
25_ecommerce_app/src/index.js -> Wrap App with FilterContext Provider
NOT ADDING UI BUT JUST THE FUNCTIONALITY OF FILTERING HERE
We can technically jam everything is product context but it would later be difficult to manage as the context would have filtering, searching, switch between list and grid view and so on. So it would be better to add filter functionality in a separate context. So let's do it in filter context.
Also note that we have a clear filter option here. When user starts filtering the product based on different filters, the products get narrowed and we see only fewer products on screen. Then when we need to get back all the products as before, we need to clear the filters. So we need two instances of products. One for the filtered one and the other one is shown in its original state after clearing the filters
So this is the idea we are going to focus on
We loaded products in products context initially like this
Now in the below image you can see that we can filter the products and the products will narrow down based on the filter. Once we click on clear filters, the actual products need to be shown. How do we do this?
Let's say we have products array that we get from initial load which is in product_context as shown above in first bullet point.
We need to get that products from product_context to filter context somehow (which I will explain in a moment below)
In the filtered context we now will have products (all products).
But what if user applies different filters, then the products change, and then when user clicks on clear filter we can't get back original products we loaded above (all_products)
For this reason, we should not apply filters on all_products, but instead make a copy(using spread operator so we dont alter original array) of this all_products inside filter_context and then apply filters on that products.
Ok too much info above. Let's break this into simple steps and work on them
We will see how to get products from products_context into filtered_context and set that products in filtered context
In filtered_context, we will set two state (reducer) values, one for all_products and one for filtered_products. Both will be same initially (but remember to use spread operator to make copies of products so we don't modify same products reference. If we do that then we technically alter original products and we don't want that. That means filtered_products will alter original products which is bad and we can't get back original products)
Ok so let's implement this and then understand while implementing
Here's my filter_context initially. I define the initial state that has filtered_products and all_products (initially they will be set to same) that we get from products_context
context/filter_context.js
// see we have a state for filtered_products and all_products as explained belowconstinitialState= { filtered_products: [], all_products: [],}constFilterContext=React.createContext()exportconstFilterProvider= ({ children }) => {const [state,dispatch] =useContext(reducer, initialState)return ( <FilterContext.Providervalue="filter context"> {children} </FilterContext.Provider> )}// make sure useexportconstuseFilterContext= () => {returnuseContext(FilterContext)}
Now how do we get the products from products_context to sset into filtered_products and all_products. Here's how we get
Now how do we wrap our app with this Filter Provider? currently this is how index looks
This is the error you get if you set it up as above
The above error is because you are importing products from products context into filter context with the Filter Provider being the parent. If Product Provider is the parent then this will work.
Note that if you uncomment the products code in filter context this would work which proves the point regarding the error above.
import { LOAD_PRODUCTS, SET_LISTVIEW, SET_GRIDVIEW, UPDATE_SORT, SORT_PRODUCTS, UPDATE_FILTERS, FILTER_PRODUCTS, CLEAR_FILTERS,} from'../actions'constfilter_reducer= (state, action) => {if (action.type ===LOAD_PRODUCTS) {return {...state, all_products: [...action.payload],// spreading out values are extemely important here so that we are deep copying now. In that way both all_products and filtered_products point to different memory location. During filtering the products we just modify the filtered_products and don't touch all_products filtered_products: [...action.payload], } }thrownewError(`No Matching "${action.type}" - action type`)}exportdefault filter_reducer
18. Products page - Grid View and List View of Products List
25_ecommerce_app/src/pages/ProductsPage.js
25_ecommerce_app/src/components/ProductList.js
25_ecommerce_app/src/context/filter_context.js
25_ecommerce_app/src/components/GridView.js
25_ecommerce_app/src/components/ListView.js
Let's now work in Products page to show products on the screen. This is what we need to design.
To start with, the above code looks like this and the page looks as below
In ProductsList component, we return 3 things
Grid View of products
List View of products
When the filters don't match then we need to display "Sorry, no match" text
Let's get the filteredProducts from filter_context.
exportconstFilterProvider= ({ children }) => {const [state,dispatch] =useReducer(reducer, initialState)const { products } =useProductsContext()// console.log('The products are', products)useEffect(() => {dispatch({ type:LOAD_PRODUCTS, payload: products }) }, [products])return (// this is the products we will get in ProductsList <FilterContext.Providervalue={{...state}}> {children} </FilterContext.Provider> )}
Now let's say we also want to have list view. We need some kind of state value for now to control this. We will later add buttons to control this. For now lets add this state value in 25_ecommerce_app/src/context/filter_context.js
Now that we have List View and Grid View of products, lets work on showing sort UI next.
You can change the grid_view to false in filter_context and then you will have list view as you know. But now let's focus on Sort UI on the ProductsPage. In the next step we will work on sort functionality
Let's first make the Grid View and List View buttons work. To make it work, currently we have to switch grid_view state to true or false manually in filter_context. Let's add a function to make it work in filter_context
Let's have a state value called sort and once we change the select option, that state value changes.
compoents/Sort.js
<selectname="sort"id="sort"className="sort-input"value={sort}onChange={updateSort} >```javascript // updateSort triggers when we change the select input const updateSort = (e) => {// const name = e.target.name // for demonstration. We will use this later const value =e.target.valuedispatch({ type:UPDATE_SORT, payload: value }) }``` <optionvalue="price-lowest">price (lowest)</option> <optionvalue="price-highest">price (highest)</option> <optionvalue="name-a">name (a-z)</option> <optionvalue="name-z">name (z-a)</option> </select>
context/filter_context.js
// updateSort triggers when we change the select inputconstupdateSort= (e) => {// const name = e.target.name // for demonstration. We will use this laterconstvalue=e.target.valuedispatch({ type:UPDATE_SORT, payload: value }) }
reducer/filter_reducer.js
if (action.type ===UPDATE_SORT) {return {...state, sort:action.payload, } }
Now we have a state for changing the sort dropdown. Once we change that state by clicking the drop-down and selecting a value, then we need to run a useEffect and then sort the products accordingly and also set the sorted products to be the new products.
Let's define that useEffect in filter_context
// when sort is clicked, first the updateSort is // called and then the below useEffect will run to // change the products as per the sortuseEffect(() => {dispatch({ type:SORT_PRODUCTS }) }, [products,state.sort])
import React, { useEffect, useContext, useReducer } from'react'import reducer from'../reducers/filter_reducer'import { LOAD_PRODUCTS, SET_GRIDVIEW, SET_LISTVIEW, UPDATE_SORT, SORT_PRODUCTS, UPDATE_FILTERS, FILTER_PRODUCTS, CLEAR_FILTERS,} from'../actions'import { useProductsContext } from'./products_context'constinitialState= { filtered_products: [], all_products: [], grid_view:true, sort:'price-lowest',}constFilterContext=React.createContext()exportconstFilterProvider= ({ children }) => {const [state,dispatch] =useReducer(reducer, initialState)const { products } =useProductsContext()constsetGridView= () => {dispatch({ type:SET_GRIDVIEW }) }constsetListView= () => {dispatch({ type:SET_LISTVIEW }) }// updateSort triggers when we change the select inputconstupdateSort= (e) => {// const name = e.target.name // for demonstration. We will use this laterconstvalue=e.target.valuedispatch({ type:UPDATE_SORT, payload: value }) }// when sort is clicked, first the updateSort is called and then the below useEffect will run to change the products as per the sortuseEffect(() => {dispatch({ type:SORT_PRODUCTS }) }, [products,state.sort])useEffect(() => {dispatch({ type:LOAD_PRODUCTS, payload: products }) }, [products])return ( <FilterContext.Providervalue={{ ...state, setGridView, setListView, updateSort }} > {children} </FilterContext.Provider> )}// make sure useexportconstuseFilterContext= () => {returnuseContext(FilterContext)}
reducers/filter_reducer.js
import { LOAD_PRODUCTS, SET_LISTVIEW, SET_GRIDVIEW, UPDATE_SORT, SORT_PRODUCTS, UPDATE_FILTERS, FILTER_PRODUCTS, CLEAR_FILTERS,} from'../actions'constfilter_reducer= (state, action) => {if (action.type ===LOAD_PRODUCTS) {return {...state, all_products: [...action.payload],// spreading out values are extemely important here so that we are deep copying now. In that way both all_products and filtered_products point to different memory location. During filtering the products we just modify the filtered_products and don't touch all_products filtered_products: [...action.payload], } }if (action.type ===SET_GRIDVIEW) {return {...state, grid_view:true, } }if (action.type ===SET_LISTVIEW) {return {...state, grid_view:false, } }if (action.type ===UPDATE_SORT) {return {...state, sort:action.payload, } }if (action.type ===SORT_PRODUCTS) {const { sort,filtered_products } = statelet tempProducts = [...filtered_products]if (sort ==='price-lowest') { tempProducts =tempProducts.sort((a, b) =>a.price -b.price) }if (sort ==='price-highest') { tempProducts =tempProducts.sort((a, b) =>b.price -a.price) }if (sort ==='name-a') { tempProducts =tempProducts.sort((a, b) => {returna.name.localeCompare(b.name) }) }if (sort ==='name-z') { tempProducts =tempProducts.sort((a, b) => {returnb.name.localeCompare(a.name) }) }return {...state, filtered_products: tempProducts, } }thrownewError(`No Matching "${action.type}" - action type`)}exportdefault filter_reducer
21. Filters - Product Page
25_ecommerce_app/src/context/filter_context.js
25_ecommerce_app/src/reducers/filter_reducer.js
25_ecommerce_app/src/components/Filters.js
We completed sorting, let's now work on the left part filters.
We will set the filters as controlled inputs. First let's define them in filter_context initial state as an object as we will have multiple values and we would change only one
constinitialState= { filtered_products: [], all_products: [], grid_view:true, sort:'price-lowest', filters: { text:'', category:'all', company:'all', color:'all', min_price:0, max_price:0,// this is price of the highest priced product price:0, shipping:false, },}
Notice that max_price is not the random price. It is actually the price of the highest priced product. In order for this to be the highest priced product, we need to set the max_price when we actually dispatch the products. Let's do that in filter_reducer.
if (action.type ===LOAD_PRODUCTS) {// 1st way to get max price from products array/* const maxPricedProduct = [...action.payload].reduce((acc, cur) => { if (acc.price > cur.price) { cur = acc } return cur }, 0) */// 2nd way to get max price from products arraylet maxPrice = [...action.payload].map((p) =>p.price) // price array maxPrice =Math.max(...maxPrice)return {...state, all_products: [...action.payload],// spreading out values are extemely important here so that we are deep copying now. In that way both all_products and filtered_products point to different memory location. During filtering the products we just modify the filtered_products and don't touch all_products filtered_products: [...action.payload], filters: {...state.filters, max_price: maxPrice, price: maxPrice, }, } }
Now that we have added the state, let's add filter UI and also a function to handle that. All the filter options will be controlled inputs (a single function will handle that)
context/filter_context
// when filters are changed this single function updateFilters is invoked - Similar to updateSortconstupdateFilters= (e) => { }constclearFilters= () => { }
// when filters are changed this single function updateFilters is invoked - Similar to updateSortconstupdateFilters= (e) => {let name =e.target.namelet value =e.target.valuedispatch({ type:UPDATE_FILTERS, payload: { name: name, value: value }, })}
Now at this point as the search changes the state value gets set for that search which is text inside state.filters
Now we need to run a useEffect like we did for sort where, once the search updates we need to get the products related to that search, so lets do that in filter_context
// when the filters change (for example when user types // something in search), the updateFilters is called which sets the // state of that filter. Once the filter is set, we need to fetch the // products for that search, hence this below useEffectuseEffect(() => {dispatch({ type:FILTER_PRODUCTS }) }, [products,state.filters])
Along with previously written useEffect of sort it looks this way
// when the filters change (for example when user types // something in search), // the updateFilters is called which sets the state of that filter. // Once the filter is set, we need to fetch the products for that search, // hence this below useEffectuseEffect(() => {dispatch({ type:FILTER_PRODUCTS }) }, [products,state.filters])// when sort is clicked, first the updateSort is called and then the below // useEffect will run to change the products as per the sortuseEffect(() => {dispatch({ type:SORT_PRODUCTS }) }, [products,state.sort])/* NOTE THAT : we could have combined ABOVE TWO useEffects for sort and filters into one and add two dispatches into that, but I prefer keeping them independent. BUT POINT IS, FIRST WE HAVE TO FILTER THE PRODUCTS AND THEN ONLY SORT THEM. HENCE WE NEED TO PLACE FILTER dispatch FIRST and then SORT dispatch like we have done above*/
Full Code
context/filter_context.js
import React, { useEffect, useContext, useReducer } from'react'import reducer from'../reducers/filter_reducer'import { LOAD_PRODUCTS, SET_GRIDVIEW, SET_LISTVIEW, UPDATE_SORT, SORT_PRODUCTS, UPDATE_FILTERS, FILTER_PRODUCTS, CLEAR_FILTERS,} from'../actions'import { useProductsContext } from'./products_context'constinitialState= { filtered_products: [], all_products: [], grid_view:true, sort:'price-lowest', filters: { text:'', category:'all', company:'all', color:'all', min_price:0, max_price:0,// this is price of the highest priced product price:0, shipping:false, },}constFilterContext=React.createContext()exportconstFilterProvider= ({ children }) => {const [state,dispatch] =useReducer(reducer, initialState)const { products } =useProductsContext()constsetGridView= () => {dispatch({ type:SET_GRIDVIEW }) }constsetListView= () => {dispatch({ type:SET_LISTVIEW }) }// updateSort triggers when we change the select inputconstupdateSort= (e) => {// const name = e.target.name // for demonstration. We will use this laterconstvalue=e.target.valuedispatch({ type:UPDATE_SORT, payload: value }) }// when filters are changed this single function updateFilters is invoked - Similar to updateSortconstupdateFilters= (e) => {let name =e.target.namelet value =e.target.valuedispatch({ type:UPDATE_FILTERS, payload: { name: name, value: value }, }) }constclearFilters= () => {}// when the filters change (for example when user types something in search), the updateFilters is called which sets the state of that filter. Once the filter is set, we need to fetch the products for that search, hence this below useEffectuseEffect(() => {dispatch({ type:FILTER_PRODUCTS }) }, [products,state.filters])// when sort is clicked, first the updateSort is called and then the below useEffect will run to change the products as per the sortuseEffect(() => {dispatch({ type:SORT_PRODUCTS }) }, [products,state.sort])/* NOTE THAT : we could have combined ABOVE TWO useEffects for sort and filters into one and add two dispatches into that, but I prefer keeping them independent. BUT POINT IS, FIRST WE HAVE TO FILTER THE PRODUCTS AND THEN ONLY SORT THEM. HENCE WE NEED TO PLACE FILTER dispatch FIRST and then SORT dispatch like we have done above*/// Load the productsuseEffect(() => {dispatch({ type:LOAD_PRODUCTS, payload: products }) }, [products])return ( <FilterContext.Providervalue={{...state, setGridView, setListView, updateSort, updateFilters, clearFilters, }} > {children} </FilterContext.Provider> )}// make sure useexportconstuseFilterContext= () => {returnuseContext(FilterContext)}
reducer/filter_reducer.js
import { LOAD_PRODUCTS, SET_LISTVIEW, SET_GRIDVIEW, UPDATE_SORT, SORT_PRODUCTS, UPDATE_FILTERS, FILTER_PRODUCTS, CLEAR_FILTERS,} from'../actions'constfilter_reducer= (state, action) => {if (action.type ===LOAD_PRODUCTS) {// 1st way to get max price from products array/* const maxPricedProduct = [...action.payload].reduce((acc, cur) => { if (acc.price > cur.price) { cur = acc } return cur }, 0) */// 2nd way to get max price from products arraylet maxPrice = [...action.payload].map((p) =>p.price) // price array maxPrice =Math.max(...maxPrice)return {...state, all_products: [...action.payload],// spreading out values are extemely important here so that we are deep copying now. In that way both all_products and filtered_products point to different memory location. During filtering the products we just modify the filtered_products and don't touch all_products filtered_products: [...action.payload], filters: {...state.filters, max_price: maxPrice, price: maxPrice, }, } }if (action.type ===SET_GRIDVIEW) {return {...state, grid_view:true, } }if (action.type ===SET_LISTVIEW) {return {...state, grid_view:false, } }// SORTif (action.type ===UPDATE_SORT) {return {...state, sort:action.payload, } }if (action.type ===SORT_PRODUCTS) {const { sort,filtered_products } = statelet tempProducts = [...filtered_products]if (sort ==='price-lowest') { tempProducts =tempProducts.sort((a, b) =>a.price -b.price) }if (sort ==='price-highest') { tempProducts =tempProducts.sort((a, b) =>b.price -a.price) }if (sort ==='name-a') { tempProducts =tempProducts.sort((a, b) => {returna.name.localeCompare(b.name) }) }if (sort ==='name-z') { tempProducts =tempProducts.sort((a, b) => {returnb.name.localeCompare(a.name) }) }return {...state, filtered_products: tempProducts, } }// FILTERSif (action.type ===UPDATE_FILTERS) {const { name,value } =action.payloadreturn {...state, filters: {...state.filters, [name]: value, }, } }if (action.type ===FILTER_PRODUCTS) {return { ...state } }thrownewError(`No Matching "${action.type}" - action type`)}exportdefault filter_reducer
exportconstformatPrice= (number) => {constformattedNumber=newIntl.NumberFormat('en-US', { style:'currency', currency:'USD', }).format(number /100) // note that we still need to divide by 100 but don't have to format the decimalsreturn formattedNumber // it automatically adds the currency representation (Adds $ at the beginning)}exportconstgetUniqueValues= (data, type) => {let unique =data.map((item) => item[type])// type companies and categories are arrays. But color type is array of arrays.// So we need to flatten if they are colorsif (type ==='colors') { unique =unique.flat() }return ['all',...newSet(unique)]}
Categories Filter
Let's now map these cateogries, colors and companies and display as filters in the UI
{/* categories */} <divclassName="form-control"> <h5>category</h5> {categories.map((category, index) => {return <buttonkey={index}>{category}</button> })} </div> {/* end of categories */}
This above code leads to this below UI
Let's add active class and stuff
<divclassName="form-control"> <h5>category</h5> {categories.map((cat, index) => {return (// we can't add value here because of which updateFilters// will not get the value like input does <buttonkey={index}onClick={updateFilters}name="category"type="button"className={cat.toLowerCase() === category ?'active':'null'} > {cat} </button> ) })} </div>
Now look at onClick function which is updateFilters. That gets e.target.name and e.target.value from the button. The e.target.name will be category but the e.target.value will be undefined as the button will not have a e.target.value unlike input. To solve this we can make use of e.target.textContent. So the updateFilters in filter_context would look like this
constupdateFilters= (e) => {let name =e.target.namelet value =e.target.valueif (name ==='category') value =e.target.textContent // this one heredispatch({ type:UPDATE_FILTERS, payload: { name: name, value: value }, }) }
Companies Filter
Similar to sort functionality we did where we use select and options
{/* colors */} <divclassName="form-control"> <h5>colors</h5> <divclassName="colors"> {colors.map((clr, index) => {return ( <buttonkey={index}name="color"style={{ background: clr }}className={`color-btn ${clr === color &&'active'}`} ></button> ) })} </div> </div> {/* end of colors */}
Now if you observe, we need to pass the e.target.value to updateFilter function like we did in text input. Since that was not possible in button for companies we used e.target.textContent. Now here in colors, both e.target.value and e.target.textContent both are not possible. Hence In this filter we will use data-color (data-set html) property.
So the take away in this section is
Use e.target.value in input
Use e.target.textContext in button
Use data-set on any other component where above two are not possible.
We need to name it as data- and then anything. data-color OR data-anything
constupdateFilters= (e) => {let name =e.target.namelet value =e.target.valueif (name ==='category') value =e.target.textContentif (name ==='color') value =e.target.dataset.colordispatch({ type:UPDATE_FILTERS, payload: { name: name, value: value }, }) }
Price Filter
Let's now work on the Price filter
{/* price */}<divclassName="form-control"> <h5>price</h5> <pclassName="price">{formatPrice(price)}</p> {/* this number range will always give us back the string the moment we modify the value, hence we need to convert this to number in updateFilter for this price */} <inputtype="range"name="price"onChange={updateFilters}min={min_price}max={max_price}value={price} /></div>{/* end of price */}
constupdateFilters= (e) => {let name =e.target.namelet value =e.target.valueif (name ==='category') value =e.target.textContentif (name ==='color') value =e.target.dataset.colorif (name ==='price') value =+value // converting from str to numdispatch({ type:UPDATE_FILTERS, payload: { name: name, value: value }, }) }
Shipping filter
{/* shipping */}<divclassName="form-control shipping"> <labelhtmlFor="shipping">free shipping</label> {/* id in the input should match the htmlFor of the label */} <inputtype="checkbox"name="shipping"id="shipping"onChange={updateFilters}// we don't look for value here, it would be // checked instead of value in a checkbox inputchecked={shipping} /></div>{/* end of shipping */}
constupdateFilters= (e) => {let name =e.target.namelet value =e.target.valueif (name ==='category') value =e.target.textContentif (name ==='color') value =e.target.dataset.colorif (name ==='price') value =+value // converting from str to numif (name ==='shipping') value =e.target.checkeddispatch({ type:UPDATE_FILTERS, payload: { name: name, value: value }, }) }
Clear Filters button
Now that we have done adding all the filters lets add a clear filter button