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
- 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.
Official React docs recommends using the following frameworks:
- Next.js
- Remix
- Gatsby
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 thanPages Router
on the top left drop down menu of the documentation.
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
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
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
, andpage
-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 ourheader
,footer
and more.
Installing Tailwind CSS with Next.js.
-
We already did the first step
-
Step 2 and paste over the commands:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
- In
tailwind.config.js
, populate thecontent
array
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
- Add Tailwind directives
Go to app/globals.css
and place at the top:
@tailwind base;
@tailwind components;
@tailwind utilities;
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.
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
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.
/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',
}
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>
)
}
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
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;
}
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.
<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>
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.
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
.
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
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 ourkey
is defined thenkey
, 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.
- 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 keyexpand
with the value as anarray
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 theasync function
in our actual component:
export default async function Home() {
const products = getStripeProducts();
return (
// ...
)
}
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',
// ...
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
You cannot randomly name components because they have reserved names.
We will create two components:
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.
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.
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!
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.
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 tomap
out the products into aProductCard
. - 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.
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">
2 Layers of Destructuring:
- Destructure out the
product
from theprops
- 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 ofprice_id
) unit_amount
- the actual price we specified in Stripe (give it an alias ofcost
)product
- an object of the product itself (its alias will beproductInfo
)
const {
id: price_id,
unit_amount: cost,
product: productInfo,
} = product;
<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>
)
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) => {
- Create a
div
underimg
then begin adding the rest of the data we destructured:name
,cost
(this is in cents so need to divide by 100), anddescription
.
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>
)
}
- 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.
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>
)
}
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).
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
.
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
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: {},
})
)
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 thenewItem
. Then when we have thenewItem
we will use theset
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 isimmutable
. 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 acart
to anewCart
-
newCart
is a new array that contains the current state of the cart, plus thenewItem
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
}
})
},
})
)
Now let's create another method called removeItemFromCart
. Quite similarly to addItemToCart
, it will take in params
.
-
From
params
we will take extract out theitemIndex
which will indicate what the index of the item we wish to remove. -
Call the
set
method which will return the...state
and thecart
assigned to anewCart
-
newCart
will be just thestate.cart.filter()
which will filter out the item we wish to remove -
Inside the
filter
, in the callback function, we will take theelement
&elementIndex
to return every item whoseelementIndex
is not the same as theitemIndex
removeItemFromCart: (params) => {
const { itemIndex } = params;
set((state) => {
const newCart = state.cart.filter((element, elementIndex) => {
return elementIndex !== itemIndex;
});
return {
...state,
cart: newCart
}
})
}
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
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
}
})
},
Now that the store is configured, we need to export it. So at the end of the file, put the line:
export default useCart;
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!
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);
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"
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!.
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...
})
)
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
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! {}
.
"A crucial part of web applications is that when you hit refresh - you generally get back to the same state"
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.
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 nameProductPage
.
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 calledlib
- Create a function that we can export, this will be the
getStripeProducts()
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;
}
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 :(
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>
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.
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);
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>
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.
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!
Refetch the product on page load on the sub-page route
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;
}
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 therequest.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);
}
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.
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
aflex-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>
)
}
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.
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.
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>
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
.
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.
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>
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>
<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
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>
// ...
))
}
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 && (
// ...
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
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>
Nextjs serves up a Nodejs back-end.
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);
}
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>
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
.
- 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
andres
-
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
renamedPOST
method tohandler
. Instead, check if the request's method is notPOST
and return / send a status of405
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
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 toroute.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);
// }
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
forcheckout()
, set theheaders
forContent-Type
:
const res = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ lineItems })
})
- Change
body
, inroute.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.
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 });
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>
)
}
Make /app/cancel/page.js
:
import Link from "next/link";
export default function CancelPage() {
return(
<div className="">
Cancelled
<Link href={'/'}>Back home!</Link>
</div>
)
}
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.
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
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
, thewanted
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 inpackage.json
), thewanted
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.
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:
-
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.
- Semver follows the format
-
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.
-
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.
- Incrementing the MAJOR version (e.g.,
- Suppose a package has version
semver helps maintain compatibility and ensures smooth package updates.
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)
(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