This is a react app that is part of John's Udemy course
This is a very big app and hence making notes of what we learn and also entire flow of the app here.
📚 Things we can learn
1. Styled components intro
import React from 'react'
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
import styled from 'styled-components'
import { Navbar, Sidebar, Footer } from './components'
// THIS IS HOW WE DEFINE STYLED COMPONENTS
const Button = styled.button`
background: green;
color: white;
`
function App() {
return (
<div>
<h4>comfy sloth starter</h4>
<Button>Hello</Button>
</div>
)
}
export default App
No name collisions in styled components that is the advantage, meaning, we can nest the styles like this
2. Service to send subscription emails
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
export const formatPrice = (number) => {
const formattedNumber = new Intl.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 decimals
return formattedNumber // it automatically adds the currency representation (Adds $ at the beginning)
}
export const getUniqueValues = () => {}
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
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
1. Starter Project
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*/}
<Route exact path="/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 */}
<Route exact path="/">
<Home />
</Route>
{/* About */}
<Route exact path="/about">
<About />
</Route>
{/* Cart */}
<Route exact path="/cart">
<Cart />
</Route>
{/* Products */}
<Route exact path="/products">
<Products />
</Route>
{/* SingleProduct -> This would be little different as it would have a children prop*/}
<Route exact path="/products/:id" children={<SingleProduct />} />
{/* Checkout -> This would be wrapped in PrivateRoute later */}
<Route exact path="/checkout">
<Checkout />
</Route>
{/* Error */}
<Route exact path="*">
<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)
Full code below
pages/index.js
import About from './AboutPage'
import AuthWrapper from './AuthWrapper'
import Cart from './CartPage'
import Checkout from './CheckoutPage'
import Error from './ErrorPage'
import Home from './HomePage'
import PrivateRoute from './PrivateRoute'
import Products from './ProductsPage'
import SingleProduct from './SingleProductPage'
export {
About,
AuthWrapper,
Cart,
Checkout,
Error,
Home,
PrivateRoute,
Products,
SingleProduct,
}
App.js
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'
function App() {
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 */}
<Route exact path="/">
<Home />
</Route>
{/* About */}
<Route exact path="/about">
<About />
</Route>
{/* Cart */}
<Route exact path="/cart">
<Cart />
</Route>
{/* Products */}
<Route exact path="/products">
<Products />
</Route>
{/* SingleProduct -> This would be little different as it would have a children prop*/}
<Route exact path="/products/:id" children={<SingleProduct />} />
{/* Checkout -> This would be wrapped in PrivateRoute later */}
<Route exact path="/checkout">
<Checkout />
</Route>
{/* Error */}
<Route exact path="*">
<Error />
</Route>
</Switch>
{/* Display footer in all pages, so it's placed outside of <Switch/> */}
<Footer />
</Router>
)
}
export default 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'
const Nav = () => {
return (
<NavContainer>
<div className="nav-center">
<div className="nav-header">
<Link to="/">
<img src={logo} alt="comfy sloth" />
</Link>
<button type="button" className="nav-toggle">
<FaBars />
</button>
</div>
{/* nav-links will also be re-used in Sidebar */}
<ul className="nav-links">
{links.map((link) => {
const { id, text, url } = link
return (
<li key={id}>
<Link to={url}>{text}</Link>
</li>
)
})}
</ul>
{/* We will add CartButtons here shortly */}
</div>
</NavContainer>
)
}
const NavContainer = 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;
}
}
`
export default 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'
const Nav = () => {
return (
<NavContainer>
<div className="nav-center">
<div className="nav-header">
<Link to="/">
<img src={logo} alt="comfy sloth" />
</Link>
<button type="button" className="nav-toggle">
<FaBars />
</button>
</div>
{/* nav-links will also be re-used in Sidebar */}
<ul className="nav-links">
{links.map((link) => {
const { id, text, url } = link
return (
<li key={id}>
<Link to={url}>{text}</Link>
</li>
)
})}
</ul>
<CartButtons />
</div>
</NavContainer>
)
}
const NavContainer = 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;
}
}
`
export default 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
Full Code for index.js
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'
import { ProductsProvider } from './context/products_context'
import { FilterProvider } from './context/filter_context'
import { CartProvider } from './context/cart_context'
import { UserProvider } from './context/user_context'
import { Auth0Provider } from '@auth0/auth0-react'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<ProductsProvider>
<App />
</ProductsProvider>
)
Since it's a very big app, instead of useState, we will use useReducer in the context to manage most of the state.
Full code
src/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'
const Nav = () => {
const { openSidebar } = useProductsContext()
return (
<NavContainer>
<div className="nav-center">
<div className="nav-header">
<Link to="/">
<img src={logo} alt="comfy sloth" />
</Link>
<button type="button" onClick={openSidebar} className="nav-toggle">
<FaBars />
</button>
</div>
{/* nav-links will also be re-used in Sidebar */}
<ul className="nav-links">
{links.map((link) => {
const { id, text, url } = link
return (
<li key={id}>
<Link to={url}>{text}</Link>
</li>
)
})}
</ul>
<CartButtons />
</div>
</NavContainer>
)
}
const NavContainer = 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;
}
}
`
export default Nav
src/components/Sidebar.js
import React from 'react'
import logo from '../assets/logo.svg'
import { Link } from 'react-router-dom'
import { useProductsContext } from '../context/products_context'
import { FaTimes } from 'react-icons/fa'
import { links } from '../utils/constants'
import styled from 'styled-components'
import CartButtons from './CartButtons'
import { useUserContext } from '../context/user_context'
const Sidebar = () => {
// later we will use context value for isOpen, for now lets hardcode this
// const isOpen = true
const { closeSidebar, isSidebarOpen } = useProductsContext()
return (
<SidebarContainer>
{/* two ways to write the className conditionally (commenting out one) */}
{/* <aside className={`${isOpen ? 'sidebar show-sidebar' : 'sidebar'}`}> */}
<aside className={`sidebar ${isSidebarOpen && 'show-sidebar'}`}>
<div className="sidebar-header">
<img src={logo} alt="comfy sloth" className="logo" />
<button type="button" onClick={closeSidebar} className="close-btn">
<FaTimes />
</button>
</div>
<ul className="links">
{links.map(({ id, text, url }) => {
return (
<li key={id}>
{/* we need to close the sidebar when any of the links is clicked */}
<Link to={url} onClick={closeSidebar}>
{text}
</Link>
</li>
)
})}
<li>
<Link to="/checkout" onClick={closeSidebar}>
Checkout
</Link>
</li>
</ul>
<CartButtons />
</aside>
</SidebarContainer>
)
}
const SidebarContainer = styled.div`
text-align: center;
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
}
.close-btn {
font-size: 2rem;
background: transparent;
border-color: transparent;
color: var(--clr-primary-5);
transition: var(--transition);
cursor: pointer;
color: var(--clr-red-dark);
margin-top: 0.2rem;
}
.close-btn:hover {
color: var(--clr-red-light);
}
.logo {
justify-self: center;
height: 45px;
}
.links {
margin-bottom: 2rem;
}
.links a {
display: block;
text-align: left;
font-size: 1rem;
text-transform: capitalize;
padding: 1rem 1.5rem;
color: var(--clr-grey-3);
transition: var(--transition);
letter-spacing: var(--spacing);
}
.links a:hover {
padding: 1rem 1.5rem;
padding-left: 2rem;
background: var(--clr-grey-10);
color: var(--clr-grey-2);
}
.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 {
transform: translate(0);
z-index: 999;
}
.cart-btn-wrapper {
margin: 2rem auto;
}
@media screen and (min-width: 992px) {
.sidebar {
display: none;
}
}
`
export default Sidebar
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
const ErrorPage = () => {
return (
<Wrapper className="page-100">
<section>
<h1>404</h1>
<h3>Sorry, the page you tried cannot be found</h3>
<Link to="/" className="btn">
Back home
</Link>
</section>
</Wrapper>
)
}
8. About and Checkout Page
25_ecommerce_app/src/pages/AboutPage.js
25_ecommerce_app/src/pages/CheckoutPage.js
25_ecommerce_app/src/components/PageHero.js
Let's design the below pages for /about and /checkout. Each of these pages also contain a hero section for heading of that page.
Full code
components/PageHero.js
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
const PageHero = ({ title }) => {
return (
<Wrapper>
<div className="section-center">
<h3>
<Link to="/">Home </Link> / {title}
</h3>
</div>
</Wrapper>
)
}
pages/AboutPage.js
import React from 'react'
import styled from 'styled-components'
import { PageHero } from '../components'
import aboutImg from '../assets/hero-bcg.jpeg'
const AboutPage = () => {
return (
<main>
<PageHero title="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 */}
<Wrapper className="page section section-center">
<img src={aboutImg} alt="Nice desk" />
<article>
<div className="title">
<h2>our story</h2>
<div className="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 imports
import { useCartContext } from '../context/cart_context'
import { Link } from 'react-router-dom'
const CheckoutPage = () => {
return (
<main>
<PageHero title="checkout" />
<Wrapper className="page">
<h3>Checkout here (Will work on this later)</h3>
</Wrapper>
</main>
)
}
const Wrapper = styled.div``
export default 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
import React from 'react'
import styled from 'styled-components'
import { services } from '../utils/constants'
const Services = () => {
return <h4>services </h4>
}
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'
export const links = [
{
id: 1,
text: 'home',
url: '/',
},
{
id: 2,
text: 'about',
url: '/about',
},
{
id: 3,
text: 'products',
url: '/products',
},
]
export const services = [
{
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',
},
]
export const products_url = 'https://course-api.com/react-store-products'
export const single_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'
const Hero = () => {
return (
<Wrapper className="section-center">
<article className="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>
<Link to="/products" className="btn hero-btn">
shop now
</Link>
</article>
<article className="img-container">
<img src={heroBcg} alt="nice table" className="main-img" />
<img src={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'
const Services = () => {
return (
<Wrapper>
<div className="section-center">
<article className="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>
<div className="services-center">
{services.map((service) => {
const { id, icon, title, text } = service
return (
<article key={id} className="service">
<span className="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'
const Contact = () => {
return (
<Wrapper>
<div className="section-center">
<h3>Join our news letter and get 20% off</h3>
<div className="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>
<form className="contact-form">
<input
type="text"
className="form-input"
placeholder="enter email"
/>
<button className="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 products
const fetchProducts = async (url) => {
const response = await axios.get(url)
console.log(response.data)
}
// fetch the products once and show the featured ones to HomePage. Show all products in /products page
useEffect(() => {
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'
const initialState = {
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
}
const ProductsContext = React.createContext()
export const ProductsProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState)
const openSidebar = () => {
dispatch({ type: SIDEBAR_OPEN })
}
const closeSidebar = () => {
dispatch({ type: SIDEBAR_CLOSE })
}
// fetch products
const fetchProducts = async (url) => {
dispatch({ type: GET_PRODUCTS_BEGIN }) // this is to set the loading to true
try {
const response = await axios.get(url)
const products = response.data
dispatch({ 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 page
useEffect(() => {
fetchProducts(url)
}, [])
return (
<ProductsContext.Provider value={{ ...state, openSidebar, closeSidebar }}>
{children}
</ProductsContext.Provider>
)
}
// make sure use
export const useProductsContext = () => {
return useContext(ProductsContext)
}
import React from 'react'
const Error = () => {
return (
<div className="section section-center text-center">
<h2>there was an error....</h2>
</div>
)
}
export default Error
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'
const Product = ({ image, name, price, id }) => {
return (
<Wrapper>
<div className="container">
<img src={image} alt={name} />
<Link to={`/products/${id}`} className="link">
<FaSearch />
</Link>
</div>
<footer>
<h5>{name}</h5>
<p>{price}</p>
</footer>
</Wrapper>
)
}
components/FeaturedProducts.js
import React from 'react'
import { useProductsContext } from '../context/products_context'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import Error from './Error'
import Loading from './Loading'
import Product from './Product'
const FeaturedProducts = () => {
const {
featured_products,
products_loading: loading,
products_error: error,
} = useProductsContext()
if (loading) {
return <Loading />
}
if (error) {
return <Error />
}
return (
<Wrapper className="section">
<div className="title">
<h2>featured products</h2>
<div className="underline"></div>
</div>
<div className="section-center featured">
{/* we will display only 3 products as featured products */}
{featured_products.slice(0, 3).map((product) => {
return <Product key={product.id} {...product} />
})}
</div>
</Wrapper>
)
}
13. Format Price
25_ecommerce_app/src/utils/helpers.js
25_ecommerce_app/src/components/Product.js
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
export const formatPrice = (number) => {
const formattedNumber = new Intl.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 decimals
return formattedNumber // it automatically adds the currency representation (Adds $ at the beginning)
}
export const getUniqueValues = () => {}
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'
const Product = ({ image, name, price, id }) => {
return (
<Wrapper>
<div className="container">
<img src={image} alt={name} />
<Link to={`/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 product
const fetchSingleProduct = async (url) => {
dispatch({ type: GET_SINGLE_PRODUCT_BEGIN }) // this is to set the loading to true for single product
try {
const response = await axios.get(url)
const singleProduct = response.data
dispatch({ 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'
const initialState = {
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: {},
}
const ProductsContext = React.createContext()
export const ProductsProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState)
const openSidebar = () => {
dispatch({ type: SIDEBAR_OPEN })
}
const closeSidebar = () => {
dispatch({ type: SIDEBAR_CLOSE })
}
// fetch products
const fetchProducts = async (url) => {
dispatch({ type: GET_PRODUCTS_BEGIN }) // this is to set the loading to true
try {
const response = await axios.get(url)
const products = response.data
dispatch({ 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 page
useEffect(() => {
fetchProducts(url)
}, [])
// fetch single product - using useCallback to avoid creating fetchSingleProduct everytime
// which is optional
const fetchSingleProduct = React.useCallback(async (url) => {
dispatch({ type: GET_SINGLE_PRODUCT_BEGIN }) // this is to set the loading to true for single product
try {
const response = await axios.get(url)
const singleProduct = response.data
dispatch({ type: GET_SINGLE_PRODUCT_SUCCESS, payload: singleProduct })
} catch (error) {
dispatch({ type: GET_SINGLE_PRODUCT_ERROR })
}
}, [])
return (
<ProductsContext.Provider
value={{ ...state, openSidebar, closeSidebar, fetchSingleProduct }}
>
{children}
</ProductsContext.Provider>
)
}
// make sure use
export const useProductsContext = () => {
return useContext(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'
const products_reducer = (state, action) => {
if (action.type === SIDEBAR_OPEN) {
return { ...state, isSidebarOpen: true }
}
if (action.type === SIDEBAR_CLOSE) {
return { ...state, isSidebarOpen: false }
}
// products and featured products
if (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) {
const products = action.payload
const featured_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 product
if (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,
}
}
throw new Error(`No Matching "${action.type}" - action type`)
}
export default 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
<Route exact path="/products/:id" children={<SingleProduct />} />
// this :id is what we can get from useParams hook inside singleProduct page
const SingleProductPage = () => {
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 behaviour
useEffect(() => {
if (error) {
setTimeout(() => {
history.push('/')
}, 3000)
}
}, [error, history])
Our code currently looks this way
const SingleProductPage = () => {
const {
fetchSingleProduct,
single_product_loading: loading,
single_product_error: error,
single_product: product,
} = useProductsContext()
const { id } = useParams()
const history = 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 behaviour
useEffect(() => {
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,
} = product
console.log(product)
return (
<Wrapper>
<PageHero title={name} product />
<div className="section section-center page">
<Link to="/products" className="btn">
Back to products
</Link>
<div className="product-center">
<ProductImages />
<section className="content">
<h2>{name}</h2>
<Stars />
<h5 className="price">{formatPrice(price)}</h5>
<p className="desc">{description}</p>
<p className="info">
<span>Available : </span>
{stock > 0 ? 'In stock' : 'Out of stock'}
</p>
<p className="info">
<span>SKU : </span>
{sku}
</p>
<p className="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 product
const PageHero = ({ title, product }) => {
return (
<Wrapper>
<div className="section-center">
<h3>
<Link to="/">Home </Link>/{' '}
{product && <Link to="/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'
const ProductImages = ({ images = [{ url: '' }] }) => {
// initially images will be empty so setting a default here to [] and adding first prop with url = '' as we are using it below
const [mainImage, setMainImage] = useState(images[0])
return (
<Wrapper>
<img src={mainImage.url} alt="main" />
<div className="gallery">
{images.map((image, index) => {
return (
<img
onClick={() => 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>
<div className="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>
<div className="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'
const AddToCart = ({ 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 context
const [mainColor, setMainColor] = useState(colors[0]) // setting first color as main color
return (
<Wrapper>
<div className="colors">
<span>colors : </span>
<div>
{colors.map((color, index) => {
return (
<button
key={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 file
className={`color-btn ${mainColor === color && 'active'}`}
style={{ backgroundColor: color }}
onClick={() => setMainColor(color)}
>
{mainColor === color && <FaCheck />}
</button>
)
})}
</div>
</div>
<div className="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 component
const increase = () => {
setAmount((oldAmount) => {
let tempAmount = oldAmount + 1
// check if amount increased is in stock or not
if (tempAmount > stock) {
tempAmount = stock
}
return tempAmount
})
}
const decrease = () => {
setAmount((oldAmount) => {
let tempAmount = oldAmount - 1
// check if amount increased is in stock or not
if (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'
const AddToCart = ({ 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 context
const [mainColor, setMainColor] = useState(colors[0]) // setting first color as main color
// state to keep track of selected number of items in cart
const [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 component
const increase = () => {
setAmount((oldAmount) => {
let tempAmount = oldAmount + 1
// check if amount increased is in stock or not
if (tempAmount > stock) {
tempAmount = stock
}
return tempAmount
})
}
const decrease = () => {
setAmount((oldAmount) => {
let tempAmount = oldAmount - 1
// check if amount increased is in stock or not
if (tempAmount < 1) {
tempAmount = 1
}
return tempAmount
})
}
return (
<Wrapper>
<div className="colors">
<span>colors : </span>
<div>
{colors.map((color, index) => {
return (
<button
key={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 file
className={`color-btn ${mainColor === color && 'active'}`}
style={{ backgroundColor: color }}
onClick={() => setMainColor(color)}
>
{mainColor === color && <FaCheck />}
</button>
)
})}
</div>
</div>
<div className="btn-container">
<AmountButtons
amount={amount}
increase={increase}
decrease={decrease}
/>
<Link to="/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'
const SingleProductPage = () => {
const {
fetchSingleProduct,
single_product_loading: loading,
single_product_error: error,
single_product: product,
} = useProductsContext()
const { id } = useParams()
const history = 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 behaviour
useEffect(() => {
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,
} = product
console.log(product)
return (
<Wrapper>
<PageHero title={name} product />
<div className="section section-center page">
<Link to="/products" className="btn">
Back to products
</Link>
<div className="product-center">
<ProductImages images={images} />
<section className="content">
<h2>{name}</h2>
<Stars stars={stars} reviews={reviews} />
<h5 className="price">{formatPrice(price)}</h5>
<p className="desc">{description}</p>
<p className="info">
<span>Available : </span>
{stock > 0 ? 'In stock' : 'Out of stock'}
</p>
<p className="info">
<span>SKU : </span>
{sku}
</p>
<p className="info">
<span>Brand : </span>
{company}
</p>
<hr />
{stock > 0 && <AddToCart product={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 below
const initialState = {
filtered_products: [],
all_products: [],
}
const FilterContext = React.createContext()
export const FilterProvider = ({ children }) => {
const [state, dispatch] = useContext(reducer, initialState)
return (
<FilterContext.Provider value="filter context">
{children}
</FilterContext.Provider>
)
}
// make sure use
export const useFilterContext = () => {
return useContext(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
index.js
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'
import { ProductsProvider } from './context/products_context'
import { FilterProvider } from './context/filter_context'
import { CartProvider } from './context/cart_context'
import { UserProvider } from './context/user_context'
import { Auth0Provider } from '@auth0/auth0-react'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<ProductsProvider>
<App />
</ProductsProvider>
)
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'
const filter_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],
}
}
throw new Error(`No Matching "${action.type}" - action type`)
}
export default 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.
export const FilterProvider = ({ 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.Provider value={{...state}}>
{children}
</FilterContext.Provider>
)
}
The productsList currently looks like this
The ProductsList code looks like this
src/components/ProductsList.js
import React from 'react'
import { useFilterContext } from '../context/filter_context'
import GridView from './GridView'
import ListView from './ListView'
const ProductList = () => {
const { filtered_products: products } = useFilterContext()
return <GridView products={products}>product list</GridView>
}
export default ProductList
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
<select
name="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.value
dispatch({ type: UPDATE_SORT, payload: value })
}
```
<option value="price-lowest">price (lowest)</option>
<option value="price-highest">price (highest)</option>
<option value="name-a">name (a-z)</option>
<option value="name-z">name (z-a)</option>
</select>
context/filter_context.js
// 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.value
dispatch({ type: UPDATE_SORT, payload: value })
}
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 sort
useEffect(() => {
dispatch({ type: SORT_PRODUCTS })
}, [products, state.sort])
reducer for this SORT_PRODUCTS looks this way
if (action.type === SORT_PRODUCTS) {
const { sort, filtered_products } = state
let 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) => {
return a.name.localeCompare(b.name)
})
}
if (sort === 'name-z') {
tempProducts = tempProducts.sort((a, b) => {
return b.name.localeCompare(a.name)
})
}
return {
...state,
filtered_products: tempProducts,
}
}
Now all 4 sort functionalities work as expected.
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'
const initialState = {
filtered_products: [],
all_products: [],
grid_view: true,
sort: 'price-lowest',
}
const FilterContext = React.createContext()
export const FilterProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState)
const { products } = useProductsContext()
const setGridView = () => {
dispatch({ type: SET_GRIDVIEW })
}
const setListView = () => {
dispatch({ type: SET_LISTVIEW })
}
// 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.value
dispatch({ 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 sort
useEffect(() => {
dispatch({ type: SORT_PRODUCTS })
}, [products, state.sort])
useEffect(() => {
dispatch({ type: LOAD_PRODUCTS, payload: products })
}, [products])
return (
<FilterContext.Provider
value={{ ...state, setGridView, setListView, updateSort }}
>
{children}
</FilterContext.Provider>
)
}
// make sure use
export const useFilterContext = () => {
return useContext(FilterContext)
}
reducers/filter_reducer.js
import {
LOAD_PRODUCTS,
SET_LISTVIEW,
SET_GRIDVIEW,
UPDATE_SORT,
SORT_PRODUCTS,
UPDATE_FILTERS,
FILTER_PRODUCTS,
CLEAR_FILTERS,
} from '../actions'
const filter_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 } = state
let 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) => {
return a.name.localeCompare(b.name)
})
}
if (sort === 'name-z') {
tempProducts = tempProducts.sort((a, b) => {
return b.name.localeCompare(a.name)
})
}
return {
...state,
filtered_products: tempProducts,
}
}
throw new Error(`No Matching "${action.type}" - action type`)
}
export default 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
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 array
let 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 updateSort
const updateFilters = (e) => {
}
const clearFilters = () => {
}
// when filters are changed this single function updateFilters is invoked - Similar to updateSort
const updateFilters = (e) => {
let name = e.target.name
let value = e.target.value
dispatch({
type: UPDATE_FILTERS,
payload: { name: name, value: value },
})
}
And for this the filter reducer looks like this
// FILTERS REDUCER PART
if (action.type === UPDATE_FILTERS) {
const { name, value } = action.payload
return {
...state,
filters: {
...state.filters,
[name]: 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 useEffect
useEffect(() => {
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 useEffect
useEffect(() => {
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 sort
useEffect(() => {
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'
const initialState = {
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,
},
}
const FilterContext = React.createContext()
export const FilterProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState)
const { products } = useProductsContext()
const setGridView = () => {
dispatch({ type: SET_GRIDVIEW })
}
const setListView = () => {
dispatch({ type: SET_LISTVIEW })
}
// 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.value
dispatch({ type: UPDATE_SORT, payload: value })
}
// when filters are changed this single function updateFilters is invoked - Similar to updateSort
const updateFilters = (e) => {
let name = e.target.name
let value = e.target.value
dispatch({
type: UPDATE_FILTERS,
payload: { name: name, value: value },
})
}
const clearFilters = () => {}
// 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 useEffect
useEffect(() => {
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 sort
useEffect(() => {
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 products
useEffect(() => {
dispatch({ type: LOAD_PRODUCTS, payload: products })
}, [products])
return (
<FilterContext.Provider
value={{
...state,
setGridView,
setListView,
updateSort,
updateFilters,
clearFilters,
}}
>
{children}
</FilterContext.Provider>
)
}
// make sure use
export const useFilterContext = () => {
return useContext(FilterContext)
}
reducer/filter_reducer.js
import {
LOAD_PRODUCTS,
SET_LISTVIEW,
SET_GRIDVIEW,
UPDATE_SORT,
SORT_PRODUCTS,
UPDATE_FILTERS,
FILTER_PRODUCTS,
CLEAR_FILTERS,
} from '../actions'
const filter_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 array
let 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,
}
}
// SORT
if (action.type === UPDATE_SORT) {
return {
...state,
sort: action.payload,
}
}
if (action.type === SORT_PRODUCTS) {
const { sort, filtered_products } = state
let 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) => {
return a.name.localeCompare(b.name)
})
}
if (sort === 'name-z') {
tempProducts = tempProducts.sort((a, b) => {
return b.name.localeCompare(a.name)
})
}
return {
...state,
filtered_products: tempProducts,
}
}
// FILTERS
if (action.type === UPDATE_FILTERS) {
const { name, value } = action.payload
return {
...state,
filters: {
...state.filters,
[name]: value,
},
}
}
if (action.type === FILTER_PRODUCTS) {
return { ...state }
}
throw new Error(`No Matching "${action.type}" - action type`)
}
export default filter_reducer
export const formatPrice = (number) => {
const formattedNumber = new Intl.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 decimals
return formattedNumber // it automatically adds the currency representation (Adds $ at the beginning)
}
export const getUniqueValues = (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 colors
if (type === 'colors') {
unique = unique.flat()
}
return ['all', ...new Set(unique)]
}
Categories Filter
Let's now map these cateogries, colors and companies and display as filters in the UI
{/* categories */}
<div className="form-control">
<h5>category</h5>
{categories.map((category, index) => {
return <button key={index}>{category}</button>
})}
</div>
{/* end of categories */}
This above code leads to this below UI
Let's add active class and stuff
<div className="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
<button
key={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
const updateFilters = (e) => {
let name = e.target.name
let value = e.target.value
if (name === 'category') value = e.target.textContent // this one here
dispatch({
type: UPDATE_FILTERS,
payload: { name: name, value: value },
})
}
Companies Filter
Similar to sort functionality we did where we use select and options
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
const updateFilters = (e) => {
let name = e.target.name
let value = e.target.value
if (name === 'category') value = e.target.textContent
if (name === 'color') value = e.target.dataset.color
dispatch({
type: UPDATE_FILTERS,
payload: { name: name, value: value },
})
}
Price Filter
Let's now work on the Price filter
{/* price */}
<div className="form-control">
<h5>price</h5>
<p className="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 */}
<input
type="range"
name="price"
onChange={updateFilters}
min={min_price}
max={max_price}
value={price}
/>
</div>
{/* end of price */}
const updateFilters = (e) => {
let name = e.target.name
let value = e.target.value
if (name === 'category') value = e.target.textContent
if (name === 'color') value = e.target.dataset.color
if (name === 'price') value = +value // converting from str to num
dispatch({
type: UPDATE_FILTERS,
payload: { name: name, value: value },
})
}
Shipping filter
{/* shipping */}
<div className="form-control shipping">
<label htmlFor="shipping">free shipping</label>
{/* id in the input should match the htmlFor of the label */}
<input
type="checkbox"
name="shipping"
id="shipping"
onChange={updateFilters}
// we don't look for value here, it would be
// checked instead of value in a checkbox input
checked={shipping}
/>
</div>
{/* end of shipping */}
const updateFilters = (e) => {
let name = e.target.name
let value = e.target.value
if (name === 'category') value = e.target.textContent
if (name === 'color') value = e.target.dataset.color
if (name === 'price') value = +value // converting from str to num
if (name === 'shipping') value = e.target.checked
dispatch({
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
Let's say we have a form in our website and we need to send some kind of subscription emails to let them know about the promotions we have. You can use a service like or
to use Redirect (react router 5) like this -
to use Navigate in (react router 6) like this -
Can be found here
To better understand this refer my stackoverfow answer