Skip to content

Latest commit

 

History

History
3156 lines (2301 loc) · 85.5 KB

journey.md

File metadata and controls

3156 lines (2301 loc) · 85.5 KB

A new journey!

I'm excited to start on another project, this time with many new things.

As you know during my previous project job-tracker, during the months of March 2023 to June 2023 the React documentation was still in beta but almost complete.

It was during that time when React finally promoted the beta docs to the official React documentation!

You can still see the old documentation in https://legacy.reactjs.org

What's new?

  • Hooks and Functional Components as Default (a strong usage of Hooks and functional components as the standard approach)
  • Interactive demos and challenges

And one of the biggest changes:

  • Removal of the Create React App (CRA), the official Facebook-maintained React quick-configuration tool. The legacy docs recommended CRA as "the best way to start building a new single-page application in React"

CRA had a lack of features (such as native support for TypeScript or CSS library Tailwind), size, performance. Just like Blizzard's StarCraft 2, CRA is no longer maintained.

What to use then!?

Official React docs recommends using the following frameworks:

  • Next.js
  • Remix
  • Gatsby

Next.js

Next.js is a full-stack React framework. It's versatile and lets you create React apps of any size - from a mostly static blog to a complex dynamic application.

To create a new Next.js project, run in the terminal:

npx create-next-app
  • Important: when referencing the documentation, make sure to have App Router rather than Pages Router on the top left drop down menu of the documentation.

Project Introduction

A full-stack e-commerce application using:

  • Next.js and React as the front-end of our application
  • Stripe API for product and transaction handling
  • TailwindCSS to design and stylize our app
  • Zustand for global state management

Project Instructions

Let's start by creating the folder you want to store the project in, in my case I called it ecommerce-store-nextjs.

In the terminal (powershell, etc.):

mkdir ecommerce-store-nextjs
cd ecommerce-store-nextjs

Then create a new Next.js project:

npx create-next-app@latest ecommerce-store-nextjs
  • Created a Next.js app with @latest
  • Give a name for the project : ecommerce-store-nextjs

Hitting [Enter] will give us configurations to do for our terminal.

Here is the prompt:

Need to install the following packages:
  create-next-app@13.4.4
Ok to proceed? (y) y
√ What is your project named? ... ecommerce-store-nextjs
√ Would you like to use TypeScript with this project? ... No / Yes
√ Would you like to use ESLint with this project? ... No / Yes
√ Would you like to use Tailwind CSS with this project? ... No / Yes
√ Would you like to use `src/` directory with this project? ... No / Yes
√ Use App Router (recommended)? ... No / Yes
√ Would you like to customize the default import alias? ... No / Yes
  • Yes to proceed, named the project
  • No to using TypeScript for now
  • Yes to ESLint
  • Yes to Tailwind CSS
  • No for src/ directory
  • Yes for App Router
  • No to import alias as in we will keep the alias the same

File Structure

Let's look at the file structure. Looks quite standard to Nextjs, but some changes in NextJS_13 is the app directory we selected to use:

  • Instead of having /pages, /components, and /src directories everything is now done in the /app directory.

  • globals, layout, and page

-layout and page are reserved keywords that do specific things within /app

  • In root level directory, within /app the file that gets rendered is /app/page.js
  • The /app/layout.js wraps our app, page.js and any sub-pages that we define. This will contain our header, footer and more.

Using Tailwind CSS with Next.js

Installing Tailwind CSS with Next.js.

  1. We already did the first step

  2. Step 2 and paste over the commands:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
  1. In tailwind.config.js, populate the content array
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
  1. Add Tailwind directives

Go to app/globals.css and place at the top:

@tailwind base;
@tailwind components;
@tailwind utilities;

Fix TailwindCSS Lint warnings

globals.css will throw out some warnings where "Unknown rule @tailwind". So here's the fix.

Use official Tailwind CSS IntelliSenses extension to extend built-in CSS language mode to fix lint warnings, without losing any of VS Code's default IntelliSense features. A StackOverflow response if you would want to use an alternative to an extension.

Add TailwindCSS to VS Code Settings under `files.associations.

To Update NextJS

During development, NextJS version can be out-of-date. NextJS version staleness has more on this, so run this command to update it to a stable release:

npm i next@latest

Start the Build Process

npm run dev

Which will start up in http://localhost:3000, so open that link up in a browser.

We will see some boiler-plate code inside page.js.

We can delete the JSX element that's being returned by Home():

export default function Home() {
  return (
    // Delete everything in here
  );
}

We can add a <main> tag to return with a className='bg-green-200 min-h-screen' to see if TailwindCSS is working.

Apply Font-Family to entire project

  • /app is the main entry-point for the component tree.
  • layout.js will wrap

Let's clear out page:

export default function Home() {
  return (
    <main className="">
      
    </main>
  )
}

Next.js allows us to apply fonts. In the layout:

import './globals.css'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

Let's start styling the body,

      <body className={'min-h-screen flex flex-col ' + inter.className}>

Notice how we leave a space right after the last style and inter.className.

Let's update the metadata:

export const metadata = {
  title: 'E-commerce store',
  description: 'E-commerce store made with NextJS',
}

Add Header and footer component

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={
        'min-h-screen flex flex-col relative ' + inter.className
      }>
        <header className=''>HEADER</header>
        <div className="flex-1">
          {children}
        </div>
        <footer className=''>FOOTER</footer>
      </body>
    </html>
  )
}

Using <head> tag

To use the icons at Font Awesome Icons (free), we need to do some set-up.

Going to use <head> tag to import Font Awesome CDN package, click the </> icon to copy the <link> tag. Paste it inside the <head>.

  • One change: have to capitalize the O in Origin and P in Policy
<link rel="stylesheet" href="..." crossOrigin="anonymous" referrerPolicy="no-referrer" />

Now go to the Font Awesome Icons (free) and search for "cart" so we can use the cart icon.

Now add these things in the <header>,

<h1>Shop</h1>
<i className="fa-solid fa-cart-shopping"></i>

Let's further add these classes to the header:

'flex item-center justify-between'

Then style the <h1>

<h1 className='uppercase cursor-pointer hover:scale-110'>Shop</h1>

Giving it a hover

Adding css transition duration

Going to add a css class using the * to target every element of the page.

In globals.css:

* {
  transition-duration: 200ms;
}

So now our hover is a bit slower.

This could be improved by targeting the header specifically.

Gave header an id='header' then gave it the specific style. Colored it purple for demonstration purposes so the changes would be seen immediately.

#header > h1 {
  transition-duration: 200ms;
  color: purple;
}

Using Link

In Next.js:

import Link from 'next/link'

<Link href={'...'}></Link>

Wrap the contents of the header in a Link tag, with an href={'/'}.

<header>
  <Link href={'/'}>
    <h1 className='uppercase cursor-pointer hover:scale-110'>Shop</h1>
    <i className="fa-solid fa-cart-shopping"></i>
  </Link>
</header>

So when user clicks the h1 at the top it will Link them back to the home page.

Learning point: flex does not apply to descendants but immediate children

<header className='flex items-center justify-between sticky'
  <Link href={'/'}>
    <h1 className='uppercase cursor-pointer hover:scale-110'>Shop</h1>
    <i className="fa-solid fa-cart-shopping"></i>
  </Link>
</header>

What's the issue above? Well the flex items-center justify-between should be putting the h1 and i elements apart. But it does not. Why? They are both wrapped in Link and flex only applies to the immediate children of header which is just Link in this case.

The fix: bring out <i> so now it would be a sibling element to Link and flex will apply.

<header className='flex items-center justify-between sticky'
  <Link href={'/'}>
    <h1 className='uppercase cursor-pointer hover:scale-110'>Shop</h1>
  </Link>
  <i className="fa-solid fa-cart-shopping"></i>
</header>

Stripe

Head on over to Stripe, create an account. Go to the Dashboard, in the top left where it says (New Business) click that and create a New Account.

Going to call it e-commerce store, click Create Account.

It will offer you use case or product go with Try Connect thats about "Run a platform or marketplace".

At this point just exit, and go to the dash board. Click Developers in the top right. Enable Test Mode. Enabling Live Mode will start billing and processing transactions.

Create .env file

Go to project directory and create .env file.

In .gitignore, confirm that this is within it:

# local env files
.env*.local

For safe measure add .env inside .gitignore:

# local env files
.env*.local
.env

This will ensure that .env file will not be pushed to any public repositories as this will store sensitive data, including API keys, passwords, etc.

Inside .env, create the Stripe key:

STRIPE_SECRET="YOUR_SECRET_STRIPE_KEY";

Find that your secret stripe key, in the Dashboard > Developers > API Keys.

Click > [Reveal test key] next to Secret key > Copy the entire key > post it inside as the value to STRIPE_SECRET and save .env file.

With this in place we can now install stripe.

Install Stripe

In the terminal, we are probably still running our npm run dev. Type [CTRL] + [C] on the keyboard to terminate the run operation in the Terminal. Press Y to confirm in the terminal to terminate batch job.

Then let's install Stripe:

npm i stripe

Next13's new feature: Getting Static information

Historically, we would've used getStaticProps() which we no longer have to do that.

We don't have to use any user fix or useState to handle loading data in server side.

We can just define an asynchronous function and we can turn our Home page into an asynchronous function as well.

This means the Home page will be pulled in asynchronously server-side before the page actually gets rendered.

  • By default all pages are server-side rendered in Next13, it means its extremely quick and data is loaded instantaneously and avoiding any client-side rendering.

Let's try it out: in the page.js

  • Add async to the Home page component
export default async function Home() {

Create async function:

import Stripe from 'stripe'

async function getStripeProducts(){
  const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
    apiVersion: '2020-08-27'
  });
}
  • In that function we initialize Stripe inside of the function server-side by passing in our API key as the first argument. We will provide a back-up of an empty string through the use of nullish coalescing operator. If our key is defined then key, otherwise if it is not defined then '' or an empty string.

  • The next parameter is setting the Stripe API version, which is '2020-08-27', by doing so allows us to avoid any surprises in your production code when you decide to upgrade your account’s default API version later on.

Set a Stripe API version.

  • After initializing, get a response that contains our data to access all of our products.

Stripe API: List all Prices in Node.js. As you can see we await stripe.prices.list({}); to GET our data.

  • Inside of the list() method, we pass in an object that has 1 key expand with the value as an array that contains the string 'data.product'.

The expand parameter in Stripe API is used to expand nested objects. When you use the expand parameter with stripe.prices.list(), it will expand the product object associated with each price object. This means that you can access the product object’s properties without having to make a separate API call.

async function getStripeProducts(){
  // Initialize Stripe
  const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
    apiVersion: '2020-08-27'
  });

  // Access our product data
  const res = await stripe.prices.list({
    expand: ['data.product']
  });
  • Next we store that response's data under a variable prices and return it
async function getStripeProducts(){
  // Initialize Stripe
  const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
    apiVersion: '2020-08-27'
  });

  // Access our product data, by returning a list of prices
  const res = await stripe.prices.list({
    expand: ['data.product']
  });

  // Access the prices of our product data
  const prices = res.data;

  return prices;
}
  • Now this will be accessible as products after invoking the async function in our actual component:
export default async function Home() {
  
  const products = getStripeProducts();
  
  return (
    // ...
  )
}

Creating Products

Go to Stripe Dashboard and click Products.

Click Add a Product, fill out the information regarding the product. Make sure to click One time as the payment.

Now click "Save product" in the top right.

Create our 3 products, upload images, etc.

Now we can access our product data through this:

  // Access our product data, by returning a list of prices
  const res = await stripe.prices.list({
    expand: ['data.product']
  });

Let's go ahead and log our products to see them:

export default async function Home() {
  const products = getStripeProducts();
  console.log(products);
  // ...

Lets boot up our app with npm run dev.

This is what was recorded in the terminal:

Promise {
  <pending>,
  [Symbol(async_id_symbol)]: 907,
  [Symbol(trigger_async_id_symbol)]: 904,
  [Symbol(kResourceStore)]: {
    headers: [Getter],
  ...

So it returns a Promise, which means we are missing something in our code. We need to add await.

export default async function Home() {
  const products = await getStripeProducts();
  console.log(products);
  // ...

Now refresh the page @ http://localhost:3000/

Now we get the actual products in the terminal:

[
  {
    id: 'price_1NHdRIJ4MEfvtz7t6WZZCDKS',
    object: 'price',
    active: true,
    billing_scheme: 'per_unit',
    // ...

Stripe response:

What is res exactly? With console.log(res), here it is logged:

{
  object: 'list',
  data: [
    {
      id: 'price_1NHdRIJ4MEfvtz7t6WZZCDKS',
      // ...
    },
    {
      id: 'price_1NHdP5J4MEfvtz7tmqJunj52',
      // ...
    },
    {
      id: 'price_1NHdMqJ4MEfvtz7tfUzEd2pz',
      // ...
    }
  ],
  has_more: false,
  url: '/v1/prices'
}

res has the properties: -object, data, has_more and url

Creating Components

You cannot randomly name components because they have reserved names.

We will create two components:

  1. loading.js

Inside /app/loading.js :

export default function loading() {
  return (
    <div>Loading...</div>
  )
}

This will be a special component that wraps our app (similar to layout). It acts in the same way that the new React tags work (<Suspense> lets you display a fallback until its children have finished loading).

They check for any promises in the children content, and if they do then loading page gets displayed.

We can see that when we refresh our page. While we are loading data, the page is automatically rendered by default. The loading behavior is all handled, without needing to have a loading state inside of our app. It is seamless, a great feature from NextJS 13.

  1. error.js

When we fetch data we typically handle an error state. This is also a reserved component name, so in /app/error.js

export default function Error() {
  return (
    <div>Error...</div>
  );
}

Because this component is in the state of being rendered server-side like our loading component, and layout, pages. We need to make this component server-side as well.

To make Error component server-side

We have to add the statement: "use client" at the top.

"use client"

export default function Error() {
  return (
    <div>Error...</div>
  );
}

If we have any errors pulling our data, we will just display this Error component. Another Next.js 13 feature!

Styling the Home Page

export default async function Home() {
  const products = await getStripeProducts();
  console.log(products);

  return (
    <main className="p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
      Main Text
    </main>
  )
}

Decided to go with a grid layout to display our products, applying responsive design on certain break points.

First actual component (not reserved name)

Let's create our first component called ProductCard.js. Let's use the "rfc" trick to create our React Functional Component using Visual Studio Code Extension for React: [ES7+ React/Redux/React-Native snippets ].

/app/ProductCard.js

import React from 'react';

export default function ProductCard(props) {
  return (
    <div>ProductCard</div>
  )
}

This component will receive some props.

We should import this in page.js and place it inside the main tag thats being returned by the Home component.

import ProductCard from './ProductCard';

export default async function Home() {
  const products = await getStripeProducts();
  console.log(products);

  return (
    <main className="p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
      {}
      <ProductCard />

    </main>
  )
}
  • Now inside the main, we want to map out the products into a ProductCard.
  • Make sure to give it an index to use as a key within the mapping
export default async function Home() {
  const products = await getStripeProducts();

  return (
    <main className="p-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">

      {products.map((product, productIndex) => {

        return (
          <ProductCard key={productIndex} product={product}/>
        )

      })}

    </main>
  )
}

Now reload the page and we will see our ProductCard text rendered out in the home page.

Refactoring the Layout

Going to move the responsive grid utility classes into a div under main. Then give main a flex flex-col class.

Like so:

export default async function Home() {
  const products = await getStripeProducts();

  return (

    <main className="p-4 flex flex-col">
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">

        {products.map((product, productIndex) => {
          // ..
        })}
      </div>
    </main>
  )
}

The reason is so that I can define a max-width of 1000px on the div element.

<main className="p-4 flex flex-col">
  <div className="max-w-[1000px] w-full mx-auto grid grid-cols-1 
  sm:grid-cols-2 md:grid-cols-3">
    {products.map((product, productIndex) => {

This means that on an even larger page, our products will self-center and won't continue to become larger.

<div className="max-w-[1000px] w-full mx-auto grid grid-cols-1 
  sm:grid-cols-2 md:grid-cols-3">

Implementing the ProductCard

2 Layers of Destructuring:

  • Destructure out the product from the props
  • Destructure out the variables from the product
export default function ProductCard(props) {
  const { product } = props;
  
  const {

  } = product;

What are the variables we need? Looking at the terminal where the products were logged, we can see some information about each product.

  • The id - the identification for the product (let's give it an alias of price_id)
  • unit_amount - the actual price we specified in Stripe (give it an alias of cost)
  • product - an object of the product itself (its alias will be productInfo)
  const {
    id: price_id,
    unit_amount: cost,
    product: productInfo,
  } = product;

Styling the ProductCard

    <div className='flex flex-col shadow bg-white hover:shadow-lg
    cursor-pointer'>
      ProductCard
    </div>

As for the Content on the page, let's use the image from the images array. First, we need to use some info from productInfo for the alt used in the img tag.

3rd Layer of Destructuring:

  const {
    name,
    description
  } = productInfo;

Then the img element:

  return (
    <div className='flex flex-col shadow bg-white hover:shadow-lg
    cursor-pointer'>
      <img src={productInfo.images[0]} alt={name} className='w-full h-full object-cover' />
    </div>
  )

Add a gap between ProductCard(s)

In /app/page.js, added a gap-4

      <div className="max-w-[1000px] w-full mx-auto grid grid-cols-1
      sm:grid-cols-2 md:grid-cols-3 gap-4">
        {products.map((product, productIndex) => {

Add more info to ProductCard

  • Create a div under img then begin adding the rest of the data we destructured: name, cost (this is in cents so need to divide by 100), and description.
  return (
    <div className='flex flex-col shadow bg-white hover:shadow-lg
    cursor-pointer'>
      <img 
        src={productInfo.images[0]} alt={name} 
        className='w-full h-full object-cover' 
      />
      <div className="flex flex-col gap-2 p-4">
        <div className="flex items-center justify-between">
          <h3>{name}</h3>
          <p>${cost/100}</p>
        </div>
        <p className='text-sm'>{description}</p>
      </div>
    </div>
  )
}

Create onClick handler that routes us to a new page

  • Will not use <Link> tag for routing, that's because we want to add a bit more functionalities for the onClick handler
  • <Link> is great for main links because it is great for Search Engine Optimization (SEO), especially if you have different pages
  • For our products however, it is not as important for now

Add this function to ProductCard.js

  function onProductClick() {
    router.push();
  }

This will push us to a new route.

We will need to import useRouter, then create our router:

import { useRouter } from 'next/navigation';

export default function ProductCard(props) {
  // ...
  const router = useRouter();
  // ...
}

ERROR: useRouter only works in Client Components. Add the "use client" directive at the top of the file to use it.

We got an error in the Chrome Dev Tools. So let's do the recommendation it told us to try.

ProductCard component can be a client component, so we can follow the recommendation by adding "use client" at the top of the file.

Pushing to a new route

If we are pushing to a new route, we need to first create the route.

Create /product folder inside /app. Inside /product, create the file page.js which is the main page export.

It will export a default function, which for now just returns a JSX div element with some text.

In /app/product/page.js:

export default function ProductPage() {
  return(
    <div>
      Hello, this is the Product Page.
    </div>
  )
}

Implementing onClick handler

Now what route do we push inside the onClick handler?

  function onProductClick() {
    router.push('/product?price_id=' + price_id);
  }

So we push to a /product with a query ?price_id= that's assigned the actual price_id of the product.

Then we assign the onClick handler to the top level div

  function onProductClick() {
    router.push('/product?price_id=' + price_id);
  }

  return (
    <div onClick={onProductClick} className='flex flex-col shadow bg-white hover:shadow-lg
    cursor-pointer'>
    // ...

Now that it has been assigned, when we click on a product on the page the router does re-route us by pushing the new route in the URL. The price_id is the query parameter. We are now in the Product Page.

We can return to the home page by clicking the components within the header (the logo or the shopping cart).

Implementing ProductPage

When we click on a product in the home page, it should route us with the price_id as the query. We can access this variable within the ProductPage component (the page) itself. We receive the props, destructure the searchParams from it, and log it.

export default function ProductPage(props) {
  const { searchParams } = props;
  
  console.log(searchParams);

We do in fact have the price_id logged into the terminal.

If you want to have Dynamic Routes, you can make dynamic segment by wrapping a folder's name in square brackets: [folderName]. e.g., [price_id], this would be the variable name you destructure out.

Create the folder's location under /app.

Initializing Global State for our Project | Using Zustand

At this point, we are going to set a product we select to our global state. Then read it in our second project.

Create the folder named (store) under /app. That's right it has parenthesis or round brackets () wrapping around it. This means that the app folder will not recognize it as a route. It will not look for components, or anything like that within it.

Inside /app/(store) we create a file store.js.

Kill our server (CTRL + C) for now so we can install Zustand.

zustand, a light-weight global state manager. You can even use it with persist methods to persist in localStorage. Let's install it in the terminal:

npm i zustand

Zustand | Creating the Object Store

Now in store.js import zustand and create our method useCart(), which will create a store (see the zustand docs).

import { create } from 'zustand';

const useCart = create()

create() will take a callback function that has a set and get method. Inside that we return an object store.

const useCart = create(
  (set, get) => ({
    
  })
)

This object store will contain all our parameters.

  • a cart that contains the list of all the products and their quantities
  • a product that gets selected (an object)
const useCart = create(
  (set, get) => ({
    cart: [],
    product: {},
    
  })
)

Zustand | Creating a method in the objectStore

After that, the object store will define a lot of methods that are going to be used to interact with our store.

  • addItemToCart method has a parameter, which we will destructure out for the newItem. Then when we have the newItem we will use the set method
const useCart = create(
  (set, get) => ({
    cart: [],
    product: {},
    addItemToCart: (params) => {
      const { newItem } = params;
      set((state) => {

        }
      })
    }
  })
)
  • the set method receives the current state, and it returns a new state because the state is immutable. The new state is a version based off of the original state. We cannot mutate the state directly, we set a new state.

  • Inside the set we spread the state, and set a cart to a newCart

  • newCart is a new array that contains the current state of the cart, plus the newItem

    addItemToCart: (params) => {
      const { newItem } = params;
      set((state) => {
        const newCart = [...state.cart, newItem];
        return {
          ...state,
          cart: newCart
        }
      })
    }

First method done!

const useCart = create(
  (set, get) => ({
    cart: [],
    product: {},
    addItemToCart: (params) => {
      const { newItem } = params;
      set((state) => {
        const newCart = [...state.cart, newItem];
        return {
          ...state,
          cart: newCart
        }
      })
    },
    
  })
)

Zustand | Creating another method in the objectStore

Now let's create another method called removeItemFromCart. Quite similarly to addItemToCart, it will take in params.

  • From params we will take extract out the itemIndex which will indicate what the index of the item we wish to remove.

  • Call the set method which will return the ...state and the cart assigned to a newCart

  • newCart will be just the state.cart.filter() which will filter out the item we wish to remove

  • Inside the filter, in the callback function, we will take the element & elementIndex to return every item whose elementIndex is not the same as the itemIndex

    removeItemFromCart: (params) => {
      const { itemIndex } = params;
      set((state) => {
        const newCart = state.cart.filter((element, elementIndex) => {
          return elementIndex !== itemIndex;
        });
        return {
          ...state,
          cart: newCart
        }
      })
    }

Empty method

The next method clears out the cart and empties all items. It does not take any parameters, and simply returns the state with the newCart being an empty array.

    emptyCart: () => {
      set((state) => {
        const newCart = [];
        return {
          ...state,
          cart: newCart
        }
      })
    }, // end of emptyCart

Set Product method

setProduct will take params which will contain newProduct. We call the set() method to return the current state while setting the product

    setProduct: (params) => {
      const { newProduct } = params;
      set((state) => {
        return {
          ...state,
          product: newProduct
        }
      })
    },

Export the Object Store

Now that the store is configured, we need to export it. So at the end of the file, put the line:

export default useCart;

Back to ProductCard

So now we can access the store in other components.

In ProductCard.js

import useCart from './(store)/store';

We can set the state to the product that is selected. First let's access the function:

const setProduct = useCart(state => state.setProduct);

This will now give us access to the setProduct function from the Store. We will use it for the onClick handler onProductClick.

  const setProduct = useCart(state => state.setProduct);

  function onProductClick() {
    const newProduct = {};
    setProduct();
    router.push('/product?price_id=' + price_id);
  }

We invoke setProduct, but first we create the newProduct object.

const newProduct = {
  name,
  description,
  price_id,
  cost,
  productInfo
};

newProduct now contains all the info that we need. We will pass this object into setProduct.

  function onProductClick() {
    const newProduct = {
      name,
      description,
      price_id,
      cost,
      productInfo
    };
    setProduct({ newProduct });
    router.push('/product?price_id=' + price_id);
  }

This will set newProduct to our state and re-route us to a new page. We should be able to click a ProductInfo component and get re-navigated, but this time we should have set that state.

Now on the actual ProductPage we can access that product!

Back to ProductPage

In /app/product/page.js, we can access that setProduct().

First the import:

import useCart from "../(store)/store";

Then inside we can access the state and return state.product.

export default function ProductPage(props) {
  const { searchParams, price_id  } = props;

  // Use the hook, select the state and the component will re-render on changes.
  const product = useCart(state => state.product);
  
  // ...
}

Assuming that the setProduct method works correctly from the store, then we should have access to the product inside store.js because the product: {} will be set:

const useCart = create(
  (set, get) => ({
    cart: [],
    product: {},
    setProduct: (params) => {
      const { newProduct } = params;
      set((state) => {
        return {
          ...state,
          product: newProduct
        }
      })
    },

Now let's log the product to see if it all works. So inside page.js:

import useCart from "../(store)/store";

export default function ProductPage(props) {
  const { searchParams, price_id  } = props;

  const product = useCart(state => state.product);

  // log the variables
  console.log(searchParams);
  console.log(price_id);
  console.log(product);

Issue: useRef only works in Client Components

In the terminal,

- error node_modules\use-sync-external-store\cjs\use-sync-external-store-shim\with-selector.development.js (51:16) @ useRef
- error useRef only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/react-client-hook-in-server-component
    at ProductPage (./app/product/page.js:13:77)
    at stringify (<anonymous>)
null

ProductPage is a server rendered component, where it actually has to be a client-rendered component. As we can see from the suggestion from the error: Add the "use client" directive....

We don't really need ProductPage to be loaded from the server, so let's just add it to the top of the page:

"use client"

Clicking on the product again

Back to the home page, we click on a product and we don't see anything rendered yet other than the Product Page default text. But if we check the developer tools console:

Object
page.js:14 undefined
page.js:15 Object

We consoled out the object(s):

For searchParams, in the line console.log(searchParams);

{
    "price_id": "price_1NHdRIJ4MEfvtz7t6WZZCDKS"
}

And as for page.js:15, the line console.log(product); yields:

{
    "name": "Premium Pineapple",
    "description": "Only the most organic and freshest pineapple on the market.",
    "price_id": "price_1NHdRIJ4MEfvtz7t6WZZCDKS",
    "cost": 100000,
    "productInfo": {
        "id": "prod_O3kxYAIRht8S59",
        "object": "product",
        "active": true,
        "attributes": [],
        "created": 1686448291,
        "default_price": "price_1NHdRIJ4MEfvtz7t6WZZCDKS",
        "description": "Only the most organic and freshest pineapple on the market.",
        "images": [
            "https://files.stripe.com/links/MDB8YWNjdF8xTkhIelNKNE1FZnZ0ejd0fGZsX3Rlc3RfQU85dnlCOENQS0hJbldMNTNVeGZnNEVX00QLFD33nY"
        ],
        "livemode": false,
        "metadata": {},
        "name": "Premium Pineapple",
        "package_dimensions": null,
        "shippable": null,
        "statement_descriptor": null,
        "tax_code": null,
        "type": "service",
        "unit_label": null,
        "updated": 1686448292,
        "url": null
    }
}

Which is awesome. This means we now have a Global State that we can manage, access and/or update from any of our pages!.

Set-Up for Checkout Page

Back in the object store, we need to create a method that gives the user a Modal.

This openModal will have a default value of false. Then we define a method at the top that setOpenModal which changes the openModal state.

The state variable:

    openModal: false,

The method to set the state variable:

    setOpenModal: () => {
      set((state) => {
        return {
          ...state,
          openModal: !state.openModal
        }
      })
    },

Together inside the store:

const useCart = create(
  (set, get) => ({
    cart: [],
    product: {},
    openModal: false,
    setOpenModal: () => {
      set((state) => {
        return {
          ...state,
          openModal: !state.openModal
        }
      })
    },
    // more methods...
  })
)

Fixing Issue for Nextjs Error 4094 code

When running npm run dev, this error pops out:

[Error: UNKNOWN: unknown error, readlink 'C:\Users\...\GitHub\ecommerce-store-nextjs\.next\server\app-paths-manifest.json'] {
  type: 'Error',
  errno: -4094,
  code: 'UNKNOWN',
  syscall: 'readlink',
  path: 'C:\\Users\\...\\GitHub\\ecommerce-store-nextjs\\.next\\server\\app-paths-manifest.json'
}

This happens when you update Nextjs with npm i next@latest, and next.js is trying to use parts of a previous build.

Solution: delete to .next folder. Then run npm run dev again to force nextjs to rebuild everything again. Stackoverflow response

(Technical Debt) Downside: if we refresh the ProductPage we lose access to the store

If we refresh the page with [F5], right after we clicked a ProductCard in the home-page and get re-routed, we lose access to the information of that product in the store.

Here is what it says in Chrome Dev Tools console:

{price_id: 'price_1NHdRIJ4MEfvtz7t6WZZCDKS'}price_id: "price_1NHdRIJ4MEfvtz7t6WZZCDKS"[[Prototype]]: Object
page.js:14 undefined
page.js:15 {}

page.js:15, the line console.log(product); yields an empty object! {}.

Important things to note

"A crucial part of web applications is that when you hit refresh - you generally get back to the same state"

Attempt 1: route to the homepage instead of handling the URL context

The issue with this attempt is that users cannot share URLs to products.

So inside the ProductPage:

  • We check if we don't have access to the name of the product. Consequently, that also means we don't have access to the rest of the product.
  • Then send the user back to the home page
  if(!product?.name){
    window.location.href = '/';
  }

With context:

export default function ProductPage(props) {
  const { searchParams } = props;
  const { price_id } = props;
  const product = useCart(state => state.product);

  if(!product?.name){
    window.location.href = '/';
  }

So if we refresh the page, it sends the user back to the home route.

Stripe API - Retrieving a product.

Attempt 2: Refactoring

Some notes:

  • In Nextjs, by default all components are server-side rendered.
  • Power of Nextjs is when data is fecthed on the server using Server Compoenents. It creates things in advanced rather than on the client. Reduces loading times.
  • Can name a file like page.js but the functional component inside the file does not have to have that name ProductPage.

Refactoring:

  • Defining function in a same file does save space, but would like to avoid code reduplication
  • Create a library outside of the app directory called lib
  • Create a function that we can export, this will be the getStripeProducts()

Using an external library to store functions

So inside /lib/getStripeProducts.js:

import Stripe from 'stripe';

export async function getStripeProducts(){
  // Initialize Stripe
  const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
    apiVersion: '2020-08-27'
  });

  // Access our product data, by returning a list of prices
  const res = await stripe.prices.list({
    expand: ['data.product']
  });

  // Access the prices of our product data
  const prices = res.data;

  return prices;
}

Import the function from external library

At the top of page.js inside /app, we type "import getStripeProducts" then let VSCode auto-complete from there. As we can see:

import { getStripeProducts } from '@/lib/getStripeProducts';

It uses the @ symbol! This is an "alias" to module paths.

Nextjs docs on Absolute Imports and Module Path Aliases.

Now our page.js is our server component. It is going to request this as it builds the website.

import ProductCard from './ProductCard';
import { getStripeProducts } from '@/lib/getStripeProducts';

export default async function Home() {
  const products = await getStripeProducts();

  return (
    // ...
  )
}

Issue is you can't access these server-side functions on client components as it loses the context to things such as .env variables to be able to run the fetch calls to APIs. So this doesn't work :(

Attempt 3 - Using Global Context with NextJS to store the products list

A crucial part of web applications is that when you hit refresh - you generally get back to the same state.

Instead of routing to the homepage, we should handle the URL context. This would allow users to share URLs to products.

The best solution would be to refetch the product on page load on the sub page route, the state is there.

But how do we access that state?

Check this out Using react context with NextJS13.

Let's use React Context and create a priceList as the state variable

"use client"

import { createContext } from "react";

const AppContext = createContext();

export default AppContext;

Now try to import to and wrap layout with it:

import { useState, createContext } from 'react';
import AppContext from './context/AppContext';

// ...

export default function RootLayout({ children }) {
  return (
    <AppContext.Provider>

Issue: createContext only works in a Client Component but none of its parents are marked

You're importing a component that needs createContext. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.

Refactor Context to Nextjs 13

So a bit of changes. First update AppContext, this creates the React Context Store.

Note the 'use client' directive that marks the context component as a client one.

'use client'

import { createContext, useContext, useState } from "react";

const AppContext = createContext({});

export const AppContextProvider = ({ children }) => {
  const [priceList, setPriceList] = useState(null);

  return (
    <AppContext.Provider value = {{ priceList, setPriceList }}>
      {children}
    </AppContext.Provider>
  )
};

export const useAppContext = () => useContext(AppContext);

Passing React Context to NextJS layouts

In NextJS we have a RootLayout component which is the main layout page. This is the layout.js file under /app directory. Let's import our provider and wrap the {children}.

In layout.js,

import { AppContextProvider } from './context/AppContext';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      // ...

        <div className="flex-1">
          <AppContextProvider>
            {children}
          </AppContextProvider>
        </div>
      
      // ...

IMPORTANT: Since we exported AppContext without default keyword, this also means that the import is called a Named Export, so it must be wrapped in {} curly braces otherwise you get an error.

You get Syntax Error, attempted import errors, even

Unhandled Runtime Error
NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

So make sure you put curly braces around a named export.

Now look into how the Context passes things down:

<div className="flex-1">
  <AppContextProvider>
    {children}
  </AppContextProvider>
</div>

Note how this works because the children property form the layout is later used in the context provider:

// In app/context/AppContext.js
<AppContext.Provider value = {{ priceList, setPriceList }}>
  {children}
</AppContext.Provider>

Using React Context within NextJS 13 pages

Now that the context provider setup is done, final step is to read and update the context values.

Whether its the main page (home page) or sub pages (product pages), they'll have similar code by reading the context value and updating it.

Since they are using hooks, we will need to mark them as client components (via "use client" directive).

"use client"
import { getStripeProducts } from '@/lib/getStripeProducts';
import { useAppContext } from './context/AppContext';

export default async function Home() {
  const products = await getStripeProducts();
  const { priceList, setPriceList } = useAppContext();

  setPriceList(products);

There is a problem -> Home needs to be a server-side component because of its call to Stripe API. This has access to the .env variables such as the Stripe API key that we need to access the information. So this doesn't work.

We can however, try passing the products as a prop instead.

Or store the products in the object store of Zustand.

Storing products in Object Store with Zustand

In store.js, create a new state called productList which will have an initial state of an empty array.

const useCart = create( (set, get) => ({
    productList: [],
}

Then update the state using the set method provided by the store. set takes a functio nthe receives the current state as an argument and returns the new state:

const useCart = create(
  (set, get) => ({

    addToProductList: (params) => {
      const { newProduct } = params;
      set((state) => {
        const newProductList = [...state.productList, newProduct];
        return {
          ...state,
          productList: newProductList
        }
      })
    },
  // ...

It's good practice to scope these functions in your store so that they can be accessed globally.

Issue, we are running a client side object store to store things that are fetched server-side, but we can't make Home component server-side because thats where we fetch prices!

Solution to Technical Debt! - Handle the URL context instead of the re-routing to homepage

Refetch the product on page load on the sub-page route

The fetch function to loadProduct

I want to create an async function on the product page (sub-page), where I can fetch the product based on the URL context. We call this function loadProduct, including the log statements to debug each step. We pass in price_id as part of the lineItems.

/app/product/page.js

  async function loadProduct(){

    const lineItems = {
      price_id: price_id,
    }
    console.log(`lineItems is: ${lineItems}`);

    const res = await fetch('/api/price', {
      method: 'GET',
      body: JSON.stringify({ lineItems })
    })

    console.log(`res is: ${res}`);

    const data = await res.json();

    console.log(`data is: ${data}`);

    product = data;
  }

The price route handler

Here is the template for the price route that will load the product on page-load

/app/api/price/route.js

import { NextResponse } from "next/server";
import Stripe from "stripe";

export async function GET(request) {

}

What we want to do first is check if request's body does not contain any lineItems, then send back a 405 response. Then in the try..catch if some error occured then respond with a 500.

export async function GET(request) {
  const body = await request.json();

  if(body.lineItems.length === 0){
    return new Response('Error', {
      status: 405,
    });
  }

  try{
    // ...

  } catch(err) {
    console.log("-------- error on product page load --------");
    console.log(err);
    return new Response('Error', {
      status: 500,
    });
  }
}

Then let's fill out the try.. part of the function.

  • Initialize Stripe
  • Destructure the price_id from the request.body.lineItems
  • Retrieve the price
  • Send a successful response
  try{
    // Initialize Stripe
    const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
      apiVersion: '2020-08-27'
    });

    // Destructure out price_id from the request body
    const { price_id } = body.lineItems;

    // Access our product data, by retrieving the price given the id
    const res = await stripe.prices.retrieve(
      price_id
    );

    return NextResponse.json({ res });

  } catch(err) {

Change this to prices.list with product parameter

  try{
    // Initialize Stripe
    const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
      apiVersion: '2020-08-27'
    });

    const res = await stripe.prices.list({
      expand: ['data.product']
    });

    return NextResponse.json({ res });

  } catch(err) {

Here is the loadProduct function so far with the log statements:

  async function loadProduct(){

    const lineItems = {
      price_id: price_id,
    }
    console.log(`lineItems is: ${lineItems}`);

    const response = await fetch('/api/price', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ lineItems })
    })

    console.log("================ Response ======================");
    console.log(response);
    console.log("================ End of Response ======================");

    const data = await response.json();

    console.log("================ data ======================");
    console.log(data);
    console.log("================ End of data ======================");

    console.log(data.res.data);
    let dataArr = data.res.data;
    let x = dataArr.filter(item => item.id === price_id);
    console.log(x); // x is the product

    let y = x[0].product;
    console.log(y);
    product = data;

    console.log("================ product ======================");
    console.log(product);
    console.log("================ End of product ======================");
  }

  loadProduct();

As you can see we can the price list through the data.res.data variable. This yields an array of JavaScript objects that contain our product, unit_amount, images and more.

We only need to one of these objects so we filter through them by id.

Then we are left with the exact product.

Let's move this filtering to the server-side to reduce load on the client.

Let's work through it, so in /price/route.js

    try{
    // Initialize Stripe
    const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
      apiVersion: '2020-08-27'
    });

    // Fetch a list of prices
    const res = await stripe.prices.list({
      expand: ['data.product']
    });

    

    const price = 1;

    return NextResponse.json({ price });

  } catch(err) {

Here the res response is this json

{res: {}}
  res : 
    data : (3) [{}, {}, {}]
    has_more : false
    object : "list"
    url: "/v1/prices"
    [[Prototype]] : Object
  [[Prototype]] : Object

So we need to access the second layer: res.res and also access that data array. This is true only if it was on the client-side because it returns a response object that wraps the response.

So in client-side it is res.res.data as we can see here when in product page:

    const data = await response.json();

    let dataArr = data.res.data;

But on the server-side, in the price route, we can just access it without a second layer:

    // Fetch a list of prices
    const res = await stripe.prices.list({
      expand: ['data.product']
    });

    // Access price list data within the response (an array)
    const dataArr = res.data;

Now it works! We can now clean-up loadProduct

  async function loadProduct(id){
    const lineItems = {
      price_id: id,
    }

    const response = await fetch('/api/price', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ lineItems })
    })

    const data = await response.json();

    return data;
  }

Remove log statements:

  // log the variables
  // console.log('props are:')
  // console.log(props);
  // console.log('searchParams are:')
  // console.log(searchParams);
  // console.log('price_id is:')
  // console.log(price_id);
  // console.log('product is:')
  // console.log(product);

  if(!product?.name){
    // console.log("does searchParams exist?");
    // console.log(searchParams);
    window.location.href = '/';
    // const products = await getStripeProducts();
    // product = products.find(product => product.id == price_id);
  }

Creating the Product Page

Assuming all things work and we are able to get the product, let's destructure out the information we need from the product.

  // Destructure the information we need from the product
  const {
    name,
    description,
    cost,
    productInfo,
  } = product;

We can now either load product from state or fetch product on page-load:

  // Load product from state
  let product = useCart(state => state.product);

  product = await loadProduct(price_id);

Depending on which we use, we destructure the data we need to dynamically render the page.

For loading product from state:

  /* Load product from state */
  let product = useCart(state => state.product);

  /*  Destructure the information we need from the product loaded from state
      Used to dynamically render the product page
  */
  const {
    name,
    description,
    cost,
    productInfo,
  } = product;

For the fetch:

  const price = await loadProduct(price_id);
  
  const product = price.price[0];

  const {
    unit_amount: cost,
    product: productInfo,
  } = product;

  const {
    name,
    description
  } = productInfo;

Now the product is successfully loaded in, regardless of whether the product is in the state, the user shared the product page URL or the page was refreshed. Technical Debt is resolved.

Styling the Product Page

The layout:

  return(
    <div className="flex flex-col">
      <div className="grid grid-cols-1 md:grid-cols-2 w-full max-w-[1000px] mx-auto">
        
      </div>
    </div>
  )

Adding the image, name, price and description:

  return(
    <div className="flex flex-col">
      <div className="grid grid-cols-1 md:grid-cols-2 w-full max-w-[1000px] mx-auto">
        <div className="md:p-2 shadow">
          <img 
            src={productInfo.images[0]} alt={name} 
            className='w-full h-full object-cover' 
          />
        </div>
        <div className="flex flex-col gap-2 p-4">
          <div className="flex md:flex-col text-xl md:items-start items-center justify-between">
            <h3>{name}</h3>
            <p>${cost / 100}</p>
          </div>
          <p className="text-sm">{description}</p>
        </div>
      </div>
    </div>
  )

Some changes to ProductInfo container on the ProductPage:

        <div className="flex flex-col gap-2 p-4">
          <div className="flex md:flex-col text-xl md:items-start items-center
          justify-between gap-2">
            <h3>{name}</h3>
            <p className="md:text-base">${cost / 100}</p>
          </div>
          <p className="text-sm flex-1">{description}</p>
          <button className='bg-slate-700 text-white hover:bg-slate-500
          cursor-pointer ml-auto px-4 py-2'>Add to Cart</button>
        </div>
  • Added a button below the description
  • Gave description a flex-1 to push button down to the bottom right

Going to add some more padding to the top component of the Product Page:

  return(
    <div className="flex flex-col p-4">
      // ...
    </div>
  )
}

Working on the Footer

The footer can be found in layout.js. Notice that the sub-pages are also wrapped by the layout!

Let's start styling it.

<footer className='flex items-center flex-wrap justify-center
border-t border-solid border-slate-300 p-10'>
  FOOTER
</footer>

Added some icons from font-awesome for the footer:

<footer className='flex items-center flex-wrap justify-center
border-t border-solid border-slate-300'>
  <div className="text-2xl">
    <span className="pr-4">FOOTER</span>
    <i className="text-slate-700 hover:text-slate-500 cursor-pointer fa-regular fa-envelope"></i>
    <i className="pl-4 fa-brands fa-github"></i>
  </div>
</footer>

Wrapped icons in links:

<Link href={'/'} 
target="_blank" rel="noopener noreferrer">
  <i className="pl-4 fa-brands fa-github text-slate-700 hover:text-slate-500 cursor-pointer"></i>
</Link>

Stackoverflow response on target="_blank" and rel="noopener no referrer", although all current versions of major browsers automatically use this behavior of rel="noopener" for any target="_blank" see chromestatus. I still include it anyway just to be extra safe.

Functionality: Add to Cart

In our Product Page @ /app/product/page.js, let's define a function to make the Add to Cart button work.

Find the method we defined in our store and access that function. It is a typical convention to keep the names consistent to what you've saved it as in the store.

const addItemToCart = useCart(state => state.addItemToCart);
export default function ProductPage(props) {
  const { searchParams } = props;
  const { price_id } = searchParams;

  const product = useCart(state => state.product);
  const addItemToCart = useCart(state => state.addItemToCart);
  // ...

Now create the function that will handle this behavior:

  function handleAddToCart() {

  }

Looking back at the store, let's check what it expects:

    addItemToCart: (params) => {
      const { newItem } = params;
      set((state) => {
        const newCart = [...state.cart, newItem];
        return {
          ...state,
          cart: newCart
        }
      })
    }, // end of addItem

It expects a newItem.

So let's create that newItem object with the properties of price_id and quantity.

  function handleAddToCart() {
    const newItem = {
      price_id: price_id,
      quantity: 1,
    }
    addItemToCart({newItem});
  }

We pass in this newItem inside of an object. The store method addItemToCart will destructure it and set it to our cart.

Refactoring the Header

Upon adding the product to the cart, we want the header to update.

Create a new component on the root level called Header.js, a react functional component. This will be client-side, so it should have the "use client" directive at the top.

In layout.js let's move the header logic, so all the code contained within the <header> tag and put it as the return value of the Header.js component. Remember to import the Link.

"use client"
import React from 'react';
import Link from 'next/link'

export default function Header() {
  return (
    <header className='flex items-center justify-between sticky
    top-0 p-6 bg-slate-200 border-b border-solid border-blue-900
    shadow-md z-50 text-2xl sm:text-3xl md:text-4xl sm:p-8'>
      <Link href={'/'}>
        <h1 className='uppercase cursor-pointer hover:scale-110'>Shop</h1>
      </Link>
        <i className="fa-solid fa-cart-shopping cursor-pointer
          hover:text-slate-500"></i>
    </header>
  )
}

And substitute the header logic with a component in layout.js:

export default function RootLayout({ children }) {
  return (
    <html lang="en">

      <head>
        <link rel="stylesheet" href="..." />
      </head>

      <body className={'min-h-screen flex flex-col relative ' + inter.className}>

        <Header />

        <div className="flex-1">
          {children}
        </div>

Updating the Header on Add to Cart

Now that the Header logic is in a separate place, what this allows us to do is avoid polluting the layout.js when we want to add specific logic to the Header.

We can now import our store and access our cart items:

import useCart from './(store)/store';

export default function Header() {
  const cartItems = useCart(state => state.cart);

We access the state and return state.cart.

Updating the cart icon

We are going to wrap the cart icon in a div. This div will also contain a conditional render of another div containg the number of cartItems.

      <div className="relative grid place-items-center">
        {cartItems.length > 0 && (
          <div className="absolute top-0 right-0">
            <p>{cartItems.length}</p>
          </div>
        )}
        <i className="fa-solid fa-cart-shopping cursor-pointer
          hover:text-slate-500"></i>
      </div>

Let's work on the conditional render. For styling purposes, let's set that condition to true:

        {true > 0 && (
          <div className="absolute bg-blue-400 text-white rounded-full top-0 right-0">
            <p>{cartItems.length}</p>
          </div>
        )}

It looks ridiculous, so let's fix it.

Going to add these utility classes to the div to create a blue circlular background that will contain the number. It should be centered.

<div className="absolute aspect-square h-6 grid place-items-center
  bg-blue-400 text-white rounded-full top-0 right-0">

Now we have to move it with to the top right of the cart by giving it a -translate-y-full translate-x-full.

After that we see that it moves it too far out, so let's adjust by 1/2.

Adjust the text-size of the p within to small and tune it down to h-5 and it is just right.

<div className="absolute aspect-square h-5 grid place-items-center
  bg-blue-400 text-white rounded-full top-0 right-0
  -translate-y-1/2 translate-x-1/2">
  <p className='text-sm'>{cartItems.length}</p>
</div>

Now revert the conditional from true to cartItems.length > 0. Now the numbers icon will conditionally render when items are added to the cart.

Responsive cart icon and group hover

Going to make the size a bit better on mobile. Also going to give the div container a group with the icon. Giving icon group means it will listen to the parent hover state, changing its opacity.

<div className="relative cursor-pointer group grid place-items-center">
  {cartItems.length > 0 && (
    <div className="absolute aspect-square pointer-events-none h-5 sm:h-6
      grid place-items-center
      bg-blue-400 text-white rounded-full top-0 right-0
      -translate-y-1/2 translate-x-1/2">
      <p className='text-xs sm:text-sm'>{cartItems.length}</p>
    </div>
  )}
  <i className="fa-solid fa-cart-shopping cursor-pointer
    group-hover:hover:text-slate-500"></i>
</div>

Modal to display Cart

Create a react functional component called Modal.js inside /app. It will work on the client-side.

"use client"
import React from 'react';

export default function Modal() {
  return (
    <div>Modal</div>
  )
}

We are going to render this Modal to a different part of the DOM so we are going to be using React Portal.

In layout.js, right before the end of body tag, add a div with id of portal. This is where we will mount the modal.

      // ...
        <div id="portal"></div>
      </body>
    </html>
  )
}

Back in the Modal, import exactly this:

import { createPortal } from 'react-dom';

Then render the Modal to different place in the DOM:

export default function Modal() {
  return ( createPortal(
    <div>Modal</div>,
    document.getElementById('portal')
  ))
}

In Header let's add the Modal state value.

import Modal from './Modal';

export default function Header() {
  const cartItems = useCart(state => state.cart);
  const openModal = useCart(state => state.openModal);

  return (
    <header className='flex items-center justify-between sticky
    top-0 p-6 bg-slate-200 border-b border-solid border-blue-900
    shadow-md z-50 text-2xl sm:text-3xl md:text-4xl sm:p-8'>

      {openModal && (
        <Modal />
      )}

In the store, set the state variable of openModal to true temporarily to see it rendered in the Elements.

    openModal: true,

Press developer tools, inside the body the last thing before scripts should be the portal div with Modal rendered out.

Right now it should be rendered at the bottom of the screen page.

Since it is rendered in relation to the body of the document, you can style it.

export default function Modal() {
  return ( createPortal(
    <div className='fixed top-0 left-0 w-screen h-screen z-50'>
      <div className="bg-transparent absolute inset-0"></div>
      <div className="flex flex-col gap-4 p-4">
        <div>
          <h1>Cart</h1>
        </div>
      </div>
    </div>,
    document.getElementById('portal')
  ))
}

We want the modal to cover the entire page with a high z-index.

    <div className='fixed top-0 left-0 w-screen h-screen z-50'>

Then first div within will be a transparent one that occupies the entire parent container with absolute and inset-0. This will close the modal.

      <div className="bg-transparent absolute inset-0"></div>

Next is the contents of the modal. It will be flex-col. It will contain the Cart.

      <div className="flex flex-col gap-4 p-4">
        <div>
          <h1>Cart</h1>
        </div>
      </div>

Further styling, version 1

    <div className='fixed top-0 left-0 w-screen h-screen z-50'>
      <div className="bg-transparent absolute inset-0"></div>
      <div className="flex flex-col bg-white absolute 
      right-0 top-0 h-screen w-screen max-w-screen shadow-lg sm:w-96 gap-4">
        <div className='flex items-center p-6 justify-between text-xl relative'>
          <h1>Cart</h1>
          <i className="fa-solid fa-xmark"></i>
          <div className="absolute bottom-0 left-1/2-translate-x-1/2 h-[1px] w-2/3
          bg-slate-300"></div>
        </div>
      </div>
    </div>,
  • Added an icon with x mark
  • Added another div under icon which is just a line to comparmentalize

Closing the Cart Modal

Remember that the 2nd div:

<div className="bg-transparent absolute inset-0"></div>

Occupies invisibily everywhere else that isn't the Cart modal. We are going to listen to any events that click on this background. So if user clicks outside of the Cart modal, it closes it.

In other words, this is the backdrop.

The other case to close the Cart Modal is when user clicks on the icon.

So let's add the closeModal function from the state, and apply the onClick handler to both of these elements.

import useCart from './(store)/store';

export default function Modal() {
  const closeModal = useCart(state => state.setOpenModal);

  return ( createPortal(
    // ...
      <div onClick={closeModal} className="bg-transparent absolute inset-0"></div>
    // ...
          <i onClick={closeModal} className="fa-solid fa-xmark"></i>
    // ...
    ))
}

More ways to Open the Modal

In Header, when we click the cart icon we expect to open the Cart Modal.

We should set the state value of the openModal by using setOpenModal function as the onClick handler.

export default function Header() {
  const cartItems = useCart(state => state.cart);

  const openModal = useCart(state => state.openModal);
  const setOpenModal = useCart(state => state.setOpenModal);

    return (
      // ..

      <div onClick = {setOpenModal} className="relative cursor-pointer group
      grid place-items-center">
        {cartItems.length > 0 && (

      // ...

Display Cart Items inside Cart Modal

First lets grab the cartItems from the state

  const cartItems = useCart(state => state.cart);

Then create a div to contain the cartItems. Conditionally render them. If there are no items, then some message. If there are items, then map out each cartItem inside a React fragment. Rough draft:

<div>
  {cartItems.length === 0 ? (
    <p>There is nothing in your cart {":("}</p>
  ) : (
    <>
    </>
  )}
</div>

Map out all the cart items if length is not 0.

{cartItems.length === 0 ? (
  <p>There is nothing in your cart {":("}</p>
) : (
  <>
    {cartItems.map((cartItem, itemIndex) => {
      return (
        <div key={itemIndex}>{cartItem}</div>
      )
    })}
  </>
)}

Currently, the issue is that when we set it we are only setting the price_id and quantity. We also need to set the name as well.

So in /app/(store)/page.js, in the Product Page, add the name to the newItem object within the handler:

  function handleAddToCart() {
    const newItem = {
      price_id: price_id,
      quantity: 1,
      name
    }
    addItemToCart({newItem});
  }

So now we can access the name. Now we can render out the name in the Cart Modal:

<>
  {cartItems.map((cartItem, itemIndex) => {
    return (
      <div key={itemIndex}>{cartItem.name}</div>
    )
  })}
</>

Let's also add the cost so we can calculate the total cost.

  function handleAddToCart() {
    const newItem = {
      price_id: price_id,
      quantity: 1,
      name,
      cost
    }
    addItemToCart({newItem});
  }

Style the mapping a bit more while rendering extra information of the cart item:

<>
  {cartItems.map((cartItem, itemIndex) => {
    return (
      <div key={itemIndex} className='flex flex-col gap-2 px-2 
      border-l border-solid border-slate-700'>
        <div className="flex items-center justify-between">

          <h2>{cartItem.name}</h2>
          <p>${cartItem.cost / 100}</p>
        </div>
        <p>Quantity: 1</p>
      </div>
    )
  })}
</>

Give overflow-scroll and flex-1 to div container for cartItems:

``js

{cartItems.length === 0 ? ( ```

Checkout button for Modal

Let's add the checkout button at the end:

<div className="border border-solid border-slate-700 text-xl m-4 p-6 
uppercase grid place-items-center hover:opacity-60 cursor-pointer">
  Checkout
</div>

Creating API routes in Nextjs

Nextjs serves up a Nodejs back-end.

API Routes.

Create folder api under /app. Inside we create the back-end route that handles the checkout.

The file will be named checkout.js.

Inside we can make a simple route:

export async function GET(request) {
  return new Response('Hello!');
}

Let's change it to a POST route, and access the body of the request. Lastly, we should send back a status code of some sort:

export async function POST(req, res) {
  const body = JSON.parse(req.body);

  return new res.sendStatus(405);
}

Here is what we do, we will have the body store a property called lineItems.

We check if lineItems length is 0, then send a status of HTTP 405 - Method Not Allowed.

Otherwise, in a try..catch, initialize stripe and create a checkout session to create a payment system. This will take in an object that has props for success_url, cancel_url, line_items, and mode.

At the end of the try, respond with a status of 201 along with the session parsed in json.

  if(body.lineItems.length === 0){
    return new res.sendStatus(405);
  }

  try{
    // Initialize Stripe
    const stripe = new Stripe(process.env.STRIPE_SECRET ?? '', {
      apiVersion: '2020-08-27'
    });

    const session = await stripe.checkout.sessions.create({
      
    })

    return res.status(201).json({ session });

  } catch(err) {
    console.log("error on checkout");
  }

Filling out the session:

const session = await stripe.checkout.sessions.create({
  success_url: 'http://localhost:3000/success',
  cancel_url: 'http://localhost:3000/cancel',
  line_items: body.lineItems,
  mode: 'payment'
})

For the catch, log the error and send a generic server error as response:

catch(err) {
    console.log("error on checkout");
    console.log(err);
    res.sendStatus(500);
  }

Construct checkout in Modal

Create an async function named checkout in Modal.js

  async function checkout(){
    const lineItems = cartItems.map(cartItem => {
      return {
        price: cartItem.price_id,
        quantity: 1
      }
    })
    const res = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({ lineItems })
    })
    const data = await res.json();
  }

This will map out each cartItem into lineItems containing the price and quantity.

Then we fetch at the url /api/checkout with a POST method, meanwhile setting the body to that of lineItems object. This will be stored in res.

Then data variable will be the result of the response or res. The response is the Stripe session we create from /api/checkout.js.

Now also import useRouter from next/navigation

import { useRouter } from 'next/navigation';

export default function Modal() {
  // ...
  const router = useRouter();

Now push the success url using the router.

  async function checkout(){
    const lineItems = cartItems.map(cartItem => {
      return {
        price: cartItem.price_id,
        quantity: 1
      }
    })
    const res = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({ lineItems })
    })
    const data = await res.json();
    router.push(data.session.url);
  }

Then assign the checkout method as onClick of the Checkout div.

<div onClick={checkout} className="border border-solid border-slate-700
text-xl m-4 p-6 uppercase grid place-items-center hover:opacity-60 cursor-pointer">
  Checkout
</div>

Issue: POST 404 not found

In Chrome dev tools, we get the following errors:

Modal.js:19     POST http://localhost:3000/api/checkout 404 (Not Found)

VM371:1 Uncaught (in promise) SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Let's look at the problem in Chrome dev tools by checking the Network tab, hover over the Headers and see that under General the Status Code really is 404.

Solving the Issue

  • Going to try to solve it

Using NextJS docs on API Routes. The function is named handler in the example, where the following API route pages/api/user.js returns a json response with a status code of 200:

export default function handler(req, res) {
  res.status(200).json({ name: 'John Doe' })
}

For an API route to work, you need to export a function as default (a.k.a request handler), which then receives the following parameters:

  • req and res

  • To handle different HTTP methods in an API route, you can use req.method in your request handler, like so:

export default function handler(req, res) {
  if (req.method === 'POST') {
    // Process a POST request
  } else {
    // Handle any other HTTP method
  }
}
  • In checkout.js renamed POST method to handler. Instead, check if the request's method is not POST and return / send a status of 405 in this case.
import Stripe from "stripe";

export async function handler(req, res) {

  if(req.method !== 'POST') { 
    return res.sendStatus(405);
  }
  • Didn't work, still 404

Solution 2: Instead of API Routes, use Route Handlers

Route Handlers this is a feature of app router, instead of API routes we use Route Handlers.

  • Move file into a folder named checkout and rename the file to route.js

The project directory is /app/api/checkout/route.js, where the code inside route.js is from checkout.js.

We also have to revert the changes we made from the first solution, renaming the method back to POST and removing the check to handle the POST method in API route:

import Stripe from "stripe";

export async function POST(req, res) {

  // if(req.method !== 'POST') { 
  //   return res.sendStatus(405);
  // }

Issue: 500 Internal Server error in the network

That's some progress, it seems we also get a SyntaxError: Unexpected token ... is not a valid JSON. This means its a JSON issue. We can see this in the Terminal of the back-end because it is a 500 error.

In Chrome Dev tools, look at the Payload we have the lineItems object with quantity: 1 but no lineItems inside of that. This means that in Modal:

  async function checkout(){
    const lineItems = cartItems.map(cartItem => {
      return {
        price: cartItem.price_id,
        quantity: 1
      }
    })

The price property doesn't register and doesn't add itself to the payload.

Let's further debug the issue.

console.log('CART ITEM: ', cartItem);

Console:

CART ITEM:  
{price_id: 'price_1NHdRIJ4MEfvtz7t6WZZCDKS', quantity: 1, name: 'Premium Pineapple', cost: 100000}
cost
: 
100000
name
: 
"Premium Pineapple"
price_id
: 
"price_1NHdRIJ4MEfvtz7t6WZZCDKS"
quantity
: 
1
[[Prototype]]
: 
Object

Errors:

0: {price_id: 'price_1NHdRIJ4MEfvtz7t6WZZCDKS', quantity: 1, name: 'Premium Pineapple', cost: 100000}length: 1[[Prototype]]: Array(0)
Modal.js:22     POST http://localhost:3000/api/checkout 500 (Internal Server Error)

VM472:1 Uncaught (in promise) SyntaxError: Unexpected end of JSON input

Under Network Tab > Request Payload

{
    "lineItems": [
        {
            "price": "price_1NHdRIJ4MEfvtz7t6WZZCDKS",
            "quantity": 1
        }
    ]
}

It may be an issue of parsing the body, so log statement in route.js

export async function POST(req, res) {

  console.log(req.body);

  // if(req.method !== 'POST') { 
  //   return res.sendStatus(405);
  // }

  const body = JSON.parse(req.body);

Now in terminal:

- event compiled successfully in 221 ms (558 modules)
ReadableStream { locked: false, state: 'readable', supportsBYOB: false }
- error SyntaxError: Unexpected token o in JSON at position 1

It is a ReadableStream.

  • In Modal for checkout(), set the headers for Content-Type:
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ lineItems })
    })
  • Change body, in route.js

From:

  const body = JSON.parse(req.body);

To this:

export async function POST(req, res) {

  console.log(req.body);

  const body = await req.json();

This will give us the proper body of the document.

New Issue: res.sendStatus is not a function

In addition to the native Request and Response, Nexjs extends with NextRequest and NextResponse providing convenient helpers. The docs.

Let's just use NextJS's style of response:

import { NextResponse } from "next/server";

Then in route.js, we can replace instances of sendStatus like this:

  if(body.lineItems.length === 0){
    return new res.sendStatus(405);
  }

Into:

  if(body.lineItems.length === 0){
    return new NextResponse('Error', {
      status: 405,
    });
  }

Now for the session response like this:

return res.status(201).json({ session });

We convert to this:

  return NextResponse.json({ session });

Create Success Route

Make /app/success/page.js, it will contain:

import Link from "next/link";

export default function SuccessPage() {
  return(
    <div className="">
      Success!
      <Link href={'/'}>Back home!</Link>
    </div>
  )
}

Create Cancel Route

Make /app/cancel/page.js:

import Link from "next/link";

export default function CancelPage() {
  return(
    <div className="">
      Cancelled
      <Link href={'/'}>Back home!</Link>
    </div>
  )
}

Finalizing the App

Now to check for functionality, add an item to the cart and click checkout.

After the session is received, enter payment details. When filled out correctly, one should see the success page from the Success route.

We now have a functioning ecommerce store made in Nextjs 13.

Maintenance: dependency/package management

Package management, also known as dependency management, involves updating packages and dependencies within a project. Tools like npm (Node Package Manager) facilitate updating packages to their latest versions.

Useful commands:

We can run the following command to check for outdated packages in our project:

npm outdated

Wanted version of a package

The wanted column in the npm outdated command refers to the maximum version of a package that satisfies the semver range specified in your package.json. Here's what it means:

  • If a semver range is defined in your package.json, the wanted version represents the latest version within that range.
  • If there's no semver range (e.g., when running npm outdated --global or if the package isn't included in package.json), the wanted version shows the currently-installed version.

In summary, wanted indicates the version you should update to based on your package constraints. If you prefer the latest version, consider updating to the one shown in the latest column.

What's semver?

Semver (short for Semantic Versioning) is a versioning system used in the Node.js ecosystem, particularly by npm (Node Package Manager). It provides a consistent way to manage package dependencies. Here are the key points about semver:

  1. Version Format:

    • Semver follows the format MAJOR.MINOR.PATCH.
    • MAJOR: Indicates breaking changes.
    • MINOR: Introduces new features without breaking existing functionality.
    • PATCH: Fixes issues or provides backward-compatible updates.
  2. Usage in npm:

    • All packages published to npm are assumed to follow semver semantics.
    • Package authors use semver to define dependency versions bundled with their packages.
  3. Example:

    • Suppose a package has version 1.2.3.
      • Incrementing the MAJOR version (e.g., 2.0.0) implies breaking changes.
      • Incrementing the MINOR version (e.g., 1.3.0) adds features without breaking compatibility.
      • Incrementing the PATCH version (e.g., 1.2.4) includes backward-compatible fixes.

semver helps maintain compatibility and ensures smooth package updates.

Install the latest minor version of npm package

To install only the wanted versions of each npm package run the following command:

chore: Update dependencies to latest semver range

npm update --save

Or we can run npm install with specific requirements.

To install the latest minor version:

npm install package-name@"^2.x.x"

To install a package right before the latest major update run the following command:

npm install package-name@"<next-major.0.0"

For example:

npm install package-name@"<3.0.0" 

Would install the latest right before 3.0.0 (e.g. 2.11.1)

Dependency log

(May 21, 2024)

npm outdated

autoprefixer        10.4.14  10.4.19  10.4.19  node_modules/autoprefixer        ecommerce-store-nextjs
eslint               8.42.0   8.42.0    9.3.0  node_modules/eslint              ecommerce-store-nextjs
eslint-config-next   13.4.4   13.4.4   14.2.3  node_modules/eslint-config-next  ecommerce-store-nextjs
next                 13.4.7   13.5.6   14.2.3  node_modules/next                ecommerce-store-nextjs
postcss              8.4.24   8.4.38   8.4.38  node_modules/postcss             ecommerce-store-nextjs
react                18.2.0   18.2.0   18.3.1  node_modules/react               ecommerce-store-nextjs
react-dom            18.2.0   18.2.0   18.3.1  node_modules/react-dom           ecommerce-store-nextjs
stripe               12.9.0  12.18.0   15.7.0  node_modules/stripe              ecommerce-store-nextjs
tailwindcss           3.3.2    3.4.3    3.4.3  node_modules/tailwindcss         ecommerce-store-nextjs
zustand               4.3.8    4.5.2    4.5.2  node_modules/zustand             ecommerce-store-nextjs

chore: Update dependencies to latest semver range

npm update --save

Package             Current   Wanted  Latest  Location                         Depended by
eslint               8.42.0   8.42.0   9.3.0  node_modules/eslint              ecommerce-store-nextjs
eslint-config-next   13.4.4   13.4.4  14.2.3  node_modules/eslint-config-next  ecommerce-store-nextjs
next                 13.5.6   13.5.6  14.2.3  node_modules/next                ecommerce-store-nextjs
react                18.2.0   18.2.0  18.3.1  node_modules/react               ecommerce-store-nextjs
react-dom            18.2.0   18.2.0  18.3.1  node_modules/react-dom           ecommerce-store-nextjs
stripe              12.18.0  12.18.0  15.7.0  node_modules/stripe              ecommerce-store-nextjs