npm install
npm update
npm run dev
1. Create App Layout Component, Build Header Component
<Component />
in _app.js file. This will override the default App.js and the layout defined in the Layout component will persist on every pageimport Link from 'next/link';
2. Get Route Data From useRouter Hook, Create Active Links
Since we’re using a function component, Next provides a React useRouter() hook that gives us information from our router
import { useRouter } from 'next/router'
When execute, it will return a router object: const router = useRouter()
We can get information about what path we’re on from the router object by using the property router.pathname
We can use this information to set the navbar menu item to be active when a user is on that particular route
In Header.js file:
router.pathname
. Return true or falseactive
property to that navbar menu itemimport { useRouter } from 'next/router';
function Header() {
// When execute, userRouter hook returns a router object
const router = useRouter();
const user = false;
// Check if the given route matches with router.pathname
// router.pathname gives information about the route the user is on
function isActive(route) {
// return true or false
return route === router.pathname;
}
// Apply active style to menu item if the function returns true
<Menu.Item header active={isActive('/')}>
}
3. Visualize Route Changes with Progress Bar
It’s always good to display to the users visually what’s taking place within the application
We’re going to display a progress bar when loading a new page or when fetching data
Install nprogress package: npm i nprogress
Next.js provides us with a Router
object from next/router
. In this object, we have access to when the route changes. We can write a function that starts the progress bar when the route changes, end the progress bar when route change is complete or encounter error
In Header.js file:
next/router
import Router, { useRouter } from 'next/router';
import nProgress from 'nprogress';
Router.onRouteChangeStart = () => nProgress.start();
Router.onRouteChangeComplete = () => nProgress.done();
Router.onRouteChangeError = () => nProgress.done();
Style the progress bar in static/nprogress.css file and include the stylesheet in Layout.js file
1. Node + Next Server with API Routes
In our home page in pages/index.js file, when the page loads (when the Home component mounts), we want to fetch the products with API and display them on the page
Whenever our application is interacting with the outside world, such as fetching data from the database, we can use React’s useEffect() hook. Inside this hook, we can call a function that makes an API request
We will use axios, a tool to help make API requests
APIs, in the purest sense, are routes. And routes are simple functions. So in order to create routes, we create basic functions
In Next.js v.9 and newer, Next introduces API Routes. Any file (regular JS file) that is inside the pages
directory Next.js will create a route for it. So we can create an api folder inside the pages
directory that contains the route-name JS files and Next.js will automatically create the api routes for those files
For example, if we want to make a get request to ‘/api/products’ endpoint, we can just create a products.js file inside the pages/api folder. Then in this products.js file, we can write a function that sends a response with the data back to the client
Another thing to note is that the port that the API request runs on is the same port that the client makes the request. By running on the same port, we can avoid CORS (cross-origin resource sharing) errors
In pages/index.js file:
import { Fragment, useEffect } from 'react';
import axios from 'axios';
function Home() {
// useEffect hook accepts 2 arguments
// 1st arg is the effect function
// Run code inside this function to interact w / outside world
// 2nd arg is dependencies array
useEffect(() => {
getProducts();
}, []);
// axios.get() method returns a promise
// so make the getProduct function an async function
// the response we get back is in response.data object
async function getProducts() {
const url = 'http://localhost:3000/api/products';
const res = await axios.get(url);
const { data } = res;
}
return <Fragment>home</Fragment>;
}
export default Home;
In pages/api/products.js file:
import products from '../../static/products.json';
export default (req, res) => {
res.status(200).json(products);
};
2. Fetching Data on the Server with getInitialProps
With client-side rendering, we would have to wait for the component to mount before we can fetch data. With Next.js, we can fetch data before the component mounts
We do this using Next’s getInitialProps
function
Now, in order to pass the response data object coming from an API request as props to a component, we need to setup the <Component />
in our custom _app.js file to receive pageProps, data object made available as props prior to the component mounts
In pages/_app.js file:
import App from 'next/app';
import Layout from '../components/_App/Layout';
class MyApp extends App {
static async getInitialProps({ Component, ctx }) {
let pageProps = {};
// first check to see if there exists an initial props of a given component
// if there is, execute the function that accepts context object as an argument
// this is an async operation
// assign the result to pageProps object
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx);
}
return { pageProps };
}
// destructure pageProps objects that's returned from getInitialProps funct
// the <Component /> is the component of each page
// each page component now has access to the pageProps object
render() {
const { Component, pageProps } = this.props;
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
}
export default MyApp;
In pages/index.js file:
import React, { Fragment, useEffect } from 'react';
import axios from 'axios';
function Home({ products }) {
console.log(products);
return <Fragment>home</Fragment>;
}
// Fetch data and return response data as props object
// This props object can be passed to a component prior to the component mounts
// It's an async function
// NOTE: getServerSideProps does the same thing as getInitialProps function
export async function getServerSideProps() {
// fetch data on server
const url = 'http://localhost:3000/api/products';
const response = await axios.get(url);
// return response data as an object
// note: this object will be merged with existing props
return { props: { products: response.data } };
};
export default Home;
The getInitialProps method:
getInitialProps
enables server-side rendering in a page and allows you to do initial data population, it means sending the page with the data already populated from the server. This is especially useful for SEOgetInitialProps
is deprecated. If using Next.js 9.3 or newer, it’s recommended to use getStaticProps
or getServerSideProps
instead of getInitialProps
Two forms of pre-rendering for Next.js:
getStaticProps
(and getStaticPaths
if necessary). It’s great for pages that can be pre-rendered ahead of a user’s request. You can also use it with Client-side Rendering to bring in additional datagetServerSideProps
. Because Server-side Rendering results in slower performance than Static Generation, use this only if absolutely necessary1. Configure Mongo Atlas, Connect to Database
MongoDB Atlas: https://www.mongodb.com/cloud
Mongo Atlas is a cloud database service that can host our MongoDB database on a remote server
Once signed in to MongoDB Cloud, create a new project and choose the free tier. Name it FurnitureBoutique
Then create a new cluster and give the cluster a name: FurnitureBoutique
Connecting the database to our application:
Connect to database:
In next.config.js file:
MONGO_SRV: "<insert mongodb-srv path here>"
In utils/connectDb.js file:
npm i mongoose
import mongoose from 'mongoose';
const connection = {};
// Connect to database
async function connectDB() {
// If there is already a connection to db, just return
// No need to make a new connection
if (connection.isConnected) {
// Use existing database connection
console.log('Using existing connection');
return;
}
// Use new database connection when connecting for 1st time
// 1st arg is the mongo-srv path that mongo generated for our db cluster
// The 2nd arg is options object. Theses are deprecation warnings
// mongoose.connect() returns a promise
// What we get back from this is a reference to our database
const db = await mongoose.connect(process.env.MONGO_SRV, {
useCreateIndex: true,
useFindAndModify: false,
useNewUrlParser: true,
useUnifiedTopology: true
});
console.log('DB Connected');
connection.isConnected = db.connections[0].readyState;
}
export default connectDB;
Call the connectDB function in routes:
Finally, import and execute the connectDB function in the request routes files which are in the pages/api folder
Must restart the server
For example, import and execute the function in pages/api/products.js file
import connectDB from '../../utils/connectDb';
// Execute the connectDB function to connect to MongoDB
connectDB();
If we’re successfully connected to MongoDB, we should be able to see “DB Connected” logged in the console
Lastly, add the next.config.js file to the .gitignore file
2. Create Products Collection, Model Product Data
Create products collection MongoDB by importing our static products data:
npx mongoimport --uri mongodb+srv://<USERNAME>:<PASSWORD>@furnitureboutique.pikdk.mongodb.net/<DATABASE> --collection products --type json --file ./static/products.json --jsonArray
Model Product data:
We use Mongoose package to connect our application to the database. Now we will use Mongoose as ORM (Object Relational Mapper). It’s a tool that’s going to specify what each document must have for it to be added to a collection. What it must have in terms of properties and the corresponding data types and other conditions
An alternative word for a model is a schema
To create a Product model, we first need to define the product schema which contains the required fields and then call mongoose.model()
to create a new model based on the schema we define
In models/Product.js file:
new mongoose.Schema()
npm i shortid
shortid.generate()
import mongoose from 'mongoose';
import shortid from 'shortid';
const { String, Number } = mongoose.Schema.Types;
const ProductSchema = new mongoose.Schema({
name: {
type: String,
required: true
},
price: {
type: Number,
required: true
},
sku: {
type: String,
unique: true,
default: shortid.generate()
},
description: {
type: String,
required: true
},
mediaUrl: {
type: String,
required: true
}
});
export default mongoose.models.Product ||
mongoose.model('Product', ProductSchema);
Fetch Products From Mongo Database:
In pages/api/products.js file:
import Product from '../../models/Product';
import connectDB from '../../utils/connectDb';
// Execute the connectDB function to connect to MongoDB
connectDB();
export default async (req, res) => {
const products = await Product.find();
res.status(200).json(products);
};
Now when we make a request to /api/products
endpoint, we should get back the products array coming from the MongoDB database
3. Build Product Cards, Make Components Responsive
Semantic UI Card: https://react.semantic-ui.com/views/card/
We’ll use Semantic UI Card component to style the products on our home page
The pages/index.js file renders the ProductList.js component
import ProductList from '../components/Index/ProductList';
function Home({ products }) {
// console.log(products);
return <ProductList products={products} />;
}
In components/Index/ProductList.js file
<Card.Group />
component so the items will stack on top of each other on smaller size screens<Menu fluid inverted id='menu' stackable>
import { Card } from 'semantic-ui-react';
function ProductList({ products }) {
function mapProductsToItems(products) {
return products.map((product) => ({
header: product.name,
image: product.mediaUrl,
meta: `$${product.price}`,
color: 'teal',
fluid: true,
childKey: product._id,
href: `/product?_id=${product._id}`
}));
}
return (
<Card.Group
stackable
itemsPerRow='3'
centered
items={mapProductsToItems(products)}
/>
);
}
export default ProductList;
1. Get Product By Id
When we click on a product, we want to direct user to the product detail page. We make an API request to get the product by its id
In Next.js, we’re able to fetch data before the component mounts. So we can make use of Next’s getInitialProps function to fetch the data
In pages/product.js file:
import axios from 'axios';
function Product({ product }) {
console.log(product);
return <p>product</p>;
}
Product.getInitialProps = async ({ query: { _id } }) => {
const url = 'http://localhost:3000/api/product';
const payload = { params: { _id } };
const response = await axios.get(url, payload);
return { product: response.data };
};
export default Product;
Now let’s create the API endpoint/route for the endpoint we defined in pages/product.js page
In pages/api/product.js file:
import Product from '../../models/Product';
export default async (req, res) => {
// req.query is an object
const { _id } = req.query;
const product = await Product.findOne({ _id });
res.status(200).json(product);
};
2. Style Product Detail Page
We will use three components to create and style the product detail page
In components/Product folder:
In pages/product.js file:
// Spreading the product object as props using the object spread operator
<Fragment>
<ProductSummary {...product} />
<ProductAttributes {...product} />
</Fragment>
In components/Product/ProductSummary.js file:
import { Item, Label } from 'semantic-ui-react';
import AddProductToCart from './AddProductToCart';
function ProductSummary({ _id, name, mediaUrl, sku, price }) {
return (
<Item.Group>
<Item>
<Item.Image size='medium' src={mediaUrl} />
<Item.Content>
<Item.Header>{name}</Item.Header>
<Item.Description>
<p>${price}</p>
<Label>SKU: {sku}</Label>
</Item.Description>
<Item.Extra>
<AddProductToCart productId={_id} />
</Item.Extra>
</Item.Content>
</Item>
</Item.Group>
);
}
export default ProductSummary;
In components/Product/ProductAttributes.js file:
import { Button, Header } from 'semantic-ui-react';
function ProductAttributes({ description }) {
return (
<>
<Header as='h3'>About this product</Header>
<p>{description}</p>
<Button
icon='trash alternate outline'
color='red'
content='Delete Product'
/>
</>
);
}
export default ProductAttributes;
In components/Product/AddProductToCart.js file:
import { Input } from 'semantic-ui-react';
function AddProductToCart(productId) {
return (
<Input
type='number'
min='1'
placeholder='Quantity'
value={1}
action={{
color: 'orange',
content: 'Add to Cart',
icon: 'plus cart'
}}
/>
);
}
export default AddProductToCart;
3. Base URL Helper
When we fetch data in a development environment, we make request to http:localhost:3000
on local server. And when we’re in production, we’re going to use the deployment URL
Let’s write a base URL helper function that detects whether we’re in a production or development environment. We can dynamically determine this whether that’s the case or not with the help of a environment variable
In utils/baseUrl.js file:
const baseUrl =
process.env.NODE_ENV === 'production'
? 'https://deployment-url.now.sh'
: 'http://localhost:3000';
export default baseUrl;
Then wherever we use a URL to fetch data, we can replace the base URL with our baseUrl helper to generate the base URL dynamically
In pages/index.js and pages/product.js files:
import baseUrl from '../utils/baseUrl';
const url = `${baseUrl}/api/products`;
1. Delete A Product Functionality
When a user clicks on the Delete Product button, we want to display a modal asking the user to confirm the product deletion
The modal contains the cancel button and Delete button
To implement the modal functionality, we want to create a state for the modal to keep track of modal state in our application. We can use React useState() hook
When the Cancel button is clicked, we just want to close the modal, setting the modal state to false
When the Delete button is clicked, we want to make a delete API request to backend and delete the product based on id. Then redirect user to products index page
Client-side: make a delete product request to /product endpoint:
In components/Product/ProductAttributes.js file:
import React, { Fragment, useState } from 'react';
import { useRouter } from 'next/router';
import { Button, Header, Modal } from 'semantic-ui-react';
import axios from 'axios';
import baseUrl from '../../utils/baseUrl';
function ProductAttributes({ description, _id }) {
const [modal, setModal] = useState(false);
const router = useRouter();
async function handleDelete() {
const url = `${baseUrl}/api/product`;
const payload = { params: { _id } };
await axios.delete(url, payload);
// redirect to home page after delete product
router.push('/');
}
return (
<Fragment>
<Header as='h3'>About this product</Header>
<p>{description}</p>
<Button
icon='trash alternate outline'
color='red'
content='Delete Product'
onClick={() => setModal(true)}
/>
<Modal open={modal} dimmer='blurring'>
<Modal.Header>Confirm Delete</Modal.Header>
<Modal.Content>
<p>Are you sure you want to delete this product?</p>
</Modal.Content>
<Modal.Actions>
<Button content='Cancel' onClick={() => setModal(false)} />
<Button
negative
icon='trash'
labelPosition='right'
content='Delete'
onClick={handleDelete}
/>
</Modal.Actions>
</Modal>
</Fragment>
);
}
export default ProductAttributes;
Server-side: create route handler to delete product request
In pages/api/product.js file:
req.method
, we can figure out what type of request it isimport Product from '../../models/Product';
export default async (req, res) => {
switch (req.method) {
case 'GET':
await handleGetRequest(req, res);
break;
case 'DELETE':
await handleDeleteRequest(req, res);
break;
default:
res.status(405).send(`Method ${req.method} not allowed`); //405 means error with request
break;
}
};
async function handleGetRequest(req, res) {
const { _id } = req.query;
const product = await Product.findOne({ _id });
res.status(200).json(product);
}
async function handleDeleteRequest(req, res) {
const { _id } = req.query;
await Product.findOneAndDelete({ _id });
// status code 204 means success and no content is sent back
res.status(204).json({});
}
2. Building the Create Product Form
Let’s build out the Create New Product form on the /create route (pages/create.js file) that enables user to create a product. They can provide product name, price, description, a product image, and upload a product image
When the user uploads a file image, we want to display a preview of the image they just uploaded
Once the user submitted the form, we can display a success message and clear the form input fields
In pages/create.js file:
import { Fragment, useState } from 'react';
import {
Form, Input, TextArea, Button, Image, Header, Message, Icon } from 'semantic-ui-react';
const INITIAL_PRODUCT = {
name: '',
price: '',
media: '',
description: ''
};
function CreateProduct() {
const [product, setProduct] = useState(INITIAL_PRODUCT);
const [mediaPreview, setMediaPreview] = useState('');
const [success, setSuccess] = useState(false);
function handleChange(event) {
const { name, value, files } = event.target;
if (name === 'media') {
setProduct((prevState) => ({ ...prevState, media: files[0] }));
// Display preview of uploaded image
setMediaPreview(window.URL.createObjectURL(files[0]));
} else {
// Pass in the updater function to setProduct function
// Spread in the previous state object into the new state object
setProduct((prevState) => ({ ...prevState, [name]: value }));
}
}
function handleSubmit(event) {
event.preventDefault();
console.log(product);
// Empty the input fields after form submit
setProduct(INITIAL_PRODUCT);
// Display the success message to the user
setSuccess(true);
}
return (
<Fragment>
<Header as='h2' block>
<Icon name='add' color='orange' />
Create New Product
</Header>
<Form success={success} onSubmit={handleSubmit}>
<Message
success
icon='check'
header='Success!'
content='Your product has been posted'
/>
<Form.Group widths='equal'>
<Form.Field
control={Input}
name='name'
label='Name'
placeholder='Name'
value={product.name}
onChange={handleChange}
/>
<Form.Field
control={Input}
name='price'
label='Price'
placeholder='Price'
min='0.00'
step='0.01'
type='number'
value={product.price}
onChange={handleChange}
/>
<Form.Field
control={Input}
name='media'
type='file'
label='Media'
accept='image/*'
content='Select Image'
onChange={handleChange}
/>
</Form.Group>
<Image src={mediaPreview} rounded centered size='small' />
<Form.Field
control={TextArea}
name='description'
label='Description'
placeholder='Description'
value={product.description}
onChange={handleChange}
/>
<Form.Field
control={Button}
color='blue'
icon='pencil alternate'
content='Submit'
type='submit'
/>
</Form>
</Fragment>
);
}
export default CreateProduct;
3. Upload Product Image, Post Product
Create Cloudinary account, create upload preset:
Creating a new product consists of two steps:
In pages/create.js file:
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
const [loading, setLoading] = useState(false);
async function handleImageUpload() {
// Using form data constructor to get data from the form
const data = new FormData();
data.append('file', product.media);
data.append('upload_preset', 'furnitureboutique');
data.append('cloud_name', 'sungnga');
const response = await axios.post(process.env.CLOUDINARY_URL, data);
const mediaUrl = response.data.url;
return mediaUrl;
}
async function handleSubmit(event) {
event.preventDefault();
setLoading(true);
const mediaUrl = await handleImageUpload();
// console.log(mediaUrl)
const url = `${baseUrl}/api/product`;
const { name, price, description } = product;
const payload = { name, description, price, mediaUrl };
const response = await axios.post(url, payload);
console.log(response);
setLoading(false);
// Clear the form input fields after submit
setProduct(INITIAL_PRODUCT);
// Show the success message
setSuccess(true);
}
In pages/api/product.js file:
import connectDB from '../../utils/connectDb';
connectDB();
case 'POST':
await handlePostRequest(req, res);
break;
async function handlePostRequest(req, res) {
// The payload info sent on request by the client is accessible in req.body object
const { name, price, description, mediaUrl } = req.body;
// Check to see if the value for all the input fields is provided
if (!name || !price || !description || !mediaUrl) {
// status code 422 means the user hasn't provided the necessary info
return res.status(422).send('Product missing one or more fields');
}
// Create a product instance from the Product model
const newProduct = await new Product({ name, price, description, mediaUrl });
// Save the product to db
newProduct.save();
// status code 201 means a resource is created
res.status(201).json(newProduct);
}
1. Prevent, Catch Errors on Client and Server Sides
A general guideline when taking care of problems within our app is we want to try to prevent errors on the client side before they can take place on the server side
Prevent users from submitting empty product input fields:
As the current state of our app it’s possible to submit a product form without all its fields filled out and this would naturally cause an error on the server
A solution is we can disable the Submit button if one of the fields is empty
In pages/create.js file:
disabled
state when there’s a change to the product
state// Disable the Submit button. By default, it's disabled
const [disabled, setDiasbled] = useState(true);
// Whenever the product state changes, run the useEffect function
useEffect(() => {
// The Object.values() method returns an array of values of the object passed in
// The every() method takes a callback and loops through the values array
// For every element in every() method, call the Boolean method on it
// The Boolean method will return true or false if the element is empty or not
const isProduct = Object.values(product).every((el) => Boolean(el));
isProduct ? setDiasbled(false) : setDiasbled(true);
}, [product]);
<Form.Field
control={Button}
disabled={disabled || loading}
color='blue'
icon='pencil alternate'
content='Submit'
type='submit'
/>
Handle errors on client-side when making request to image upload and request to product endpoint:
A common pattern used to catch errors in asynchronous functions in executing promises is the try/catch block
try
block is the code we try to runcatch
block can catch the error. The catch block automatically receives the error and we can decide what to do with the errorfinally
block is where we want run a piece of code no matter what the outcome isThere are different types of errors we might get back and instead of console logging the error, we can display an error message to the user
Let’s write a separate function that displays an error message based on the type of error returned from the promise
In utils/catchErrors.js file:
// 1st arg is the error received from the catch block that gets passed down to this function
// 2nd arg is a callback function that receives the errorMsg as an argument
function catchErrors(error, displayError) {
let errorMsg;
if (error.response) {
// The request was made and the server response with a status code
// that is not in the range of 2xx
errorMsg = error.response.data;
console.error('Error response', errorMsg);
// For Cloudingary image uploads
if (error.response.data.error) {
errorMsg = error.response.data.error.message;
}
} else if (error.request) {
// The request was made, but no response was received
errorMsg = error.request;
console.error('Error request', errorMsg);
} else {
// Something else happened in making the request that triggered an error
errorMsg = error.message;
console.error('Error message', errorMsg);
}
displayError(errorMsg);
}
export default catchErrors;
Then use the catchErrors function in the catch block in the handleSubmit function and display the error message to the user. This is handling errors when user submits a product form to create a new product
In pages/create.js file:
import catchErrors from '../utils/catchErrors';
const [error, setError] = useState('');
async function handleSubmit(event) {
try {
event.preventDefault();
setLoading(true);
const mediaUrl = await handleImageUpload();
// console.log(mediaUrl)
const url = `${baseUrl}/api/product`;
const { name, price, description } = product;
// Triggering an error for testing
// const payload = { name: '', description, price, mediaUrl };
const payload = { name, description, price, mediaUrl };
const response = await axios.post(url, payload);
console.log(response);
// Clear the form input fields after submit
setProduct(INITIAL_PRODUCT);
// Show the success message
setSuccess(true);
} catch (error) {
// 1st arg is the error received from the promise
// 2nd arg is the function to update the error state
catchErrors(error, setError);
// console.error('ERROR!!', error)
} finally {
// At the end of handleSubmit, set loading state to false. Loading icon will go away
setLoading(false);
}
}
// Display the error message to the user
// Boolean(error) returns true or false. Error is the error state
<Form
loading={loading}
error={Boolean(error)}
success={success}
onSubmit={handleSubmit}
>
<Message error header='Oops!' content={error} />
</Form>
Handling errors on server side:
On the server side, we want to try to figure out all the potential causes of errors and give the client as much information to resolve the error on their own
If there are some errors that we don’t know about, we want to back a status code and a message about the error as well
Use try/catch block in async functions to catch the error returned from the promise
In pages/api/product.js file:
async function handlePostRequest(req, res) {
// The payload info sent on request by the client is accessible in req.body object
const { name, price, description, mediaUrl } = req.body;
try {
// Check to see if the value for all the input fields is provided
if (!name || !price || !description || !mediaUrl) {
// status code 422 means the user hasn't provided the necessary info
return res.status(422).send('Product missing one or more fields');
}
// Create a product instance from the Product model
const newProduct = await new Product({
name,
price,
description,
mediaUrl
});
// Save the product to db
newProduct.save();
// status code 201 means a resource is created
res.status(201).json(newProduct);
} catch (error) {
console.error(error);
res.status(500).send('Server error in creating product');
}
}
2. Structure Cart Page
The cart page route consists of a section that displays a list of products in their shopping cart and a section that displays the subtotal and a checkout button
In pages/cart.js file:
import { Segment } from 'semantic-ui-react';
import CartItemList from '../components/Cart/CartItemList';
import CartSummary from '../components/Cart/CartSummary';
function Cart() {
return (
<Segment>
<CartItemList />
<CartSummary />
</Segment>
);
}
export default Cart;
In components/Cart/CartItemList.js file:
import { Header, Segment, Icon, Button } from 'semantic-ui-react';
function CartItemList() {
const user = false;
return (
<Segment secondary color='teal' inverted textAlign='center'>
<Header icon>
<Icon name='shopping basket' />
No products in your cart. Add some!
</Header>
<div>
{user ? (
<Button color='orange'>View Products</Button>
) : (
<Button color='blue'>Login to Add Products</Button>
)}
</div>
</Segment>
);
}
export default CartItemList;
In components/Cart/CartSummary.js file:
import { Fragment } from 'react';
import { Segment, Button, Divider } from 'semantic-ui-react';
function CartSummary() {
return (
<Fragment>
<Divider />
<Segment clearing size='large'>
<strong>Subtotal:</strong> $0.00
<Button icon='cart' color='teal' floated='right' content='Checkout' />
</Segment>
</Fragment>
);
}
export default CartSummary;
1. Build Login and Signup Forms
Both the Login and Signup forms have very similar functionality as the Create New Product form
The Signup form has input fields of name, email, and password and a submit button
The Login form has input fields of name and password and a submit button
We use states using useEffect hooks to keep track of various aspects of the forms
In pages/signup.js file:
import { Fragment, useState, useEffect } from 'react';
import { Button, Form, Icon, Message, Segment } from 'semantic-ui-react';
import Link from 'next/link';
import catchErrors from '../utils/catchErrors';
const INITIAL_USER = {
name: '',
email: '',
password: ''
};
function Signup() {
const [user, setUser] = useState(INITIAL_USER);
const [disabled, setDisabled] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const isUser = Object.values(user).every((el) => Boolean(el));
isUser ? setDisabled(false) : setDisabled(true);
}, [user]);
function handleChange(event) {
const { name, value } = event.target;
setUser((prevState) => ({ ...prevState, [name]: value }));
}
async function handleSubmit(event) {
event.preventDefault();
try {
setLoading(true);
setError('');
console.log(user);
// make request to signup user
} catch (error) {
catchErrors(error, setError);
} finally {
setLoading(false);
}
}
return (
//the rest of the code...
)
2. Model User, Signup User with JWT and Cookies
When a user signs up by submitting the Signup form, we want to create a new user and store it in a users collection in the database. We want to create a User model that defines what a user document would look like
If a new user is successfully created, what’s returned to the client from the server is a jsonwebtoken. We then use this token to set a cookie in the browser so that we can identify this client as an authenticated user
Client-side: make a request to signup user endpoint:
In pages/signup.js file:
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
// make request to signup user
const url = `${baseUrl}/api/signup`
// Spread in the user data coming from user state
const payload = { ...user }
// What's returned from the request is a token in response.data object
const response = await axios.post(url, payload)
Create User model:
In models/User.js file:
import mongoose from 'mongoose';
const { String } = mongoose.Schema.Types;
const UserSchema = new mongoose.Schema(
{
name: {
type: String,
required: true
},
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true,
select: false
},
role: {
type: String,
required: true,
default: 'user',
enum: ['user', 'admin', 'root'] //the role field can only accept one of these three values
}
},
{
timestamps: true
}
);
export default mongoose.models.User || mongoose.model('User', UserSchema);
Server-side: create signup route handler to signup user with JWT:
In pages/api/signup.js file:
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import connectDB from '../../utils/connectDb';
import User from '../../models/User';
connectDB();
export default async (req, res) => {
const { name, email, password } = req.body;
try {
// 1) Check to see if the user already exists in the db
const user = await User.findOne({ email });
if (use) {
return res.status(422).send(`User already exist with email ${email}`);
}
// 2) --if not, hash their password
const hash = await bcrypt.hash(password, 10);
// 3) Create user
const newUser = await new User({
name,
email,
password: hash
});
newUser.save();
console.log(newUser);
// 4) Create token for the new user
// A token expires after a certain period of time
const token = jwt.sign({ userId: newUser._id }, process.env.JWT_SECRET, {
expiresIn: '7d'
});
// 5) Send back token
res.status(201).json(token);
} catch (error) {
console.error(error);
res.status(500).send('Error signup user. Please try again later');
}
};
Store the JWT token in the browser as a cookie:
Make a request to our signup endpoint -> get the token back on the client -> use a function to put the token in our browser as a cookie that can be accessed on the client or server
In utils/auth.js file:
import cookie from 'js-cookie';
import Router from 'next/router';
export function handleLogin(token) {
// 1st arg is the key. We'll call it token
// 2nd arg is the value, the given token
cookie.set('token', token);
// Redirect to the account route
Router.push('/account');
}
In pages/signup.js file:
import { handleLogin } from '../utils/auth';
async function handleSubmit(event) {
event.preventDefault();
try {
setLoading(true);
setError('');
// make request to signup user
const url = `${baseUrl}/api/signup`;
// Spread in the user data coming from user state
const payload = { ...user };
// What's returned from the request is a token in response.data object
const response = await axios.post(url, payload);
// Set cookie in the browser
handleLogin(response.data);
} catch (error) {
catchErrors(error, setError);
} finally {
setLoading(false);
}
}
3. Validate POST Content on Server Side
Right now users can enter anything they want in the input fields when they sign up. We want to set some constraints on the name, email, and password fields. We want to validate on the server side the values that are provided on the request body and then send an error back to the client and display it to users if it doesn’t meet the conditions that we set
We’re going to use a tool called validator to help us validate forms
Import validator: npm i validator
In pages/api/signup.js file:
import isEmail from 'validator/lib/isEmail';
import isLength from 'validator/lib/isLength';
if (!isLength(name, { min: 3, max: 10 })) {
return res.status(422).send('Name must be 3-10 characters long');
} else if (!isLength(password, { min: 6 })) {
return res.status(422).send('Password must be at least 6 characters');
} else if (!isEmail(email)) {
return res.status(422).send('Email must be valid');
}
4. Add Login Functionality
Client-side: make a request to login user endpoint
In pages/login.js file:
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
const url = `${baseUrl}/api/login`;
// Spread in user object, which come from user state
const payload = { ...user };
// What's returned from the request is a token in response.data object
const response = await axios.post(url, payload);
Server-side: create login user route handler with JWT
In pages/api/login.js file:
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import connectDB from '../../utils/connectDb';
import User from '../../models/User';
// Connect to the database
connectDB();
export default async (req, res) => {
const { email, password } = req.body;
try {
// 1) Check to see if a user exists with the provided email
// In User schema, we exclude password by default
// But here, we want to select the password when finding a user in the db
const user = await User.findOne({ email }).select('+password');
// 2) --if not, return error
if (!user) {
return res.status(404).send('No user exists with that email');
}
// 3) Check to see if users' password matches the one in db
// 1st arg is the password the user provided
// 2nd arg is the password in the db
// returns true or false
const passwordsMatch = await bcrypt.compare(password, user.password);
// 4) --if so, generate a token
if (passwordsMatch) {
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: '7d'
});
res.status(200).json(token);
} else {
res.status(401).send('Passwords do not match'); //401 means not authenticated
}
// 5) Send that token to the client
} catch (error) {
console.error(error);
res.status(500).send('Error logging in user');
}
};
Store the JWT token in the browser as a cookie:
Once the client receives the token from the server, we want to use a function to put the token in our browser as a cookie that can be accessed on the client or server
In pages/login.js file:
import { handleLogin } from '../utils/auth';
async function handleSubmit(event) {
event.preventDefault();
try {
setLoading(true);
setError('');
// make request to signup user
const url = `${baseUrl}/api/login`;
// Spread in use object, which comes from user state
const payload = { ...user };
// What's returned from the request is a token in response.data object
const response = await axios.post(url, payload);
// Set cookie in the browser
handleLogin(response.data);
} catch (error) {
catchErrors(error, setError);
} finally {
setLoading(false);
}
}
5. Create User Cart Upon Signup
After a user sign up and when we create a document for them in the database, we need to link the user document with a cart document in the database
So first we need to build a Cart model to define what a cart document has
Create a Cart model:
In models/Cart.js file:
import mongoose from 'mongoose';
const { ObjectId, Number } = mongoose.Schema.Types;
const CartSchema = new mongoose.Schema({
user: {
type: ObjectId,
ref: 'User' //referencing the User model
},
products: [
{
quantity: {
type: Number,
default: 1
},
product: {
type: ObjectId,
ref: 'Product' //referencing the Product model
}
}
]
});
export default mongoose.models.Cart || mongoose.model('Cart', CartSchema);
Create a cart for new user upon signup:
In pages/api/signup.js file:
import Cart from '../../models/Cart';
const cart = await new Cart({ user: newUser._id });
cart.save();
Now in MongoBD, when a new user document is created a cart document is also created that has a user field associated with the user ObjectId
1. Get Current User from Token, Protect Auth Routes
Install nookies: npm i nookies
Once a user signup or login to our application they can see different parts of the app depending on permission that they have
On our custom App page(_app.js file) we have a getInitialProps
method that gets called for each of our page components. This App component is executed on the server and it’s executed before anything else. The getInitialProps
method in this custom App component gets executed when page changes
So this is the ideal place to fetch our user’s data from token and pass it down to each of our page components and the page layout as props
Client-side: get user’s account data from token:
In pages/_app.js file:
getInitialProps
for each page component, we can check to see if the current user has a tokenheaders
. This headers has a property called Authorization
and it’s going to be set to token
that we’re getting from cookies objectimport { parseCookies } from 'nookies';
import { redirectUser } from '../utils/auth';
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
// App component is executed on the server and is executed before anything else
class MyApp extends App {
// We have access to request and response from context object
static async getInitialProps({ Component, ctx }) {
// What's returned from parseCookies is cookies object
// Destructure the token property from it
const { token } = parseCookies(ctx);
let pageProps = {};
// first check to see if there exists an initial props of a given component
// if there is, execute the function that accepts context object as an argument
// this is an async operation
// assign the result to pageProps object
if (Component.getInitialProps) {
// Execute getInitalProps for each page component
pageProps = await Component.getInitialProps(ctx);
}
// Check to see if current user has a token
if (!token) {
const isProtectedRoute =
ctx.pathname === '/account' || ctx.pathname === '/create';
// If user is unauthenticated and is on a protected route, redirect user to login page
if (isProtectedRoute) {
redirectUser(ctx, '/login');
}
} else {
// Make a request to get the user's account data from token
try {
const payload = { headers: { Authorization: token } };
const url = `${baseUrl}/api/account`;
const response = await axios.get(url, payload);
const user = response.data;
// Pass the user to the pageProps user object
// The pageProps will then pass to every page components and Layout component
pageProps.user = user;
} catch (error) {
console.error('Error getting current user', error);
}
}
// console.log(pageProps.user)
return { pageProps };
}
// destructure pageProps object that's returned from getInitialProps function
// the <Component /> is the component of each page
// each page component now has access to the pageProps object
render() {
const { Component, pageProps } = this.props;
return (
<Layout {...pageProps}>
<Component {...pageProps} />
</Layout>
);
}
}
In utils/auth.js file:
export function redirectUser(ctx, location) {
// If we have access to context, the request is on the server
// If we get a request on the server, redirect on the server
if (ctx.req) {
// Redirecting on the server with Node
ctx.res.writeHead(302, { Location: location });
// To stop writing to this response
ctx.res.end();
} else {
// Redirect on the client
Router.push(location);
}
}
Server-side: create user account route handler:
In pages/api/account.js file:
import jwt from 'jsonwebtoken';
import User from '../../models/User';
import connectDB from '../../utils/connectDb';
connectDB();
export default async (req, res) => {
// Check if authorization headers is provided with the request
// If not, we want to return early`
if (!('authorization' in req.headers)) {
return res.status(401).send('No authorization token'); //401 means not permitted
}
try {
// jwt.verify() method verifies the token
// 1st arg is the provided token
// 2nd arg is the jwt secret which we use to sign the token
// what's returned is an object. Destructure the userId property from it
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
// Use the returned userId to find a user in the database
const user = await User.findOne({ _id: userId });
if (user) {
// If user is found, return the user to the client
res.send(200).json(user);
} else {
res.status(404).send('User not found');
}
} catch (error) {
res.status(403).send('Invalid token'); //403 means forbidden action
}
};
In components/_App/Layout.js component, receive and destructure the user props. Pass down the user props to the Header component
In components/_App/Header.js component, receive and destructure the user props
Now when an unauthenticated user tries to visit the /account or /create routes, they will be redirected to login page
If a user is successfully logged in, they will be able to see and access the Create and Account links in the navbar
2. Handle Invalid Auth Tokens
If the user’s token has been expired or has been tampered with or somehow malfunctioned, we want to delete the invalid token and redirect user to login page so they can login again
In page/_app.js file:
import { parseCookies, destroyCookie } from 'nookies';
catch (error) {
console.error('Error getting current user', error);
// 1) Throw out invalid token
destroyCookie(ctx, 'token')
// 2) Redirect to login route
redirectUser(ctx, '/login')
}
3. Protect Admin Routes, Hide Protected Content
Users may have permission to certain pages depending on their role. For example, a user with the role of “user” does not have permission to create a product. Only admin users and root users have permission to create route. So for regular users, the Create link in the navbar will be hidden
In page/_app.js file:
const response = await axios.get(url, payload);
const user = response.data;
const isRoot = user.role === 'root';
const isAdmin = user.role === 'admin';
// If authenticated, but not of role 'admin' or 'root', redirect from '/create/' page
const isNotPermitted =
!(isRoot || isAdmin) && ctx.pathname === '/create';
if (isNotPermitted) {
redirectUser(ctx, '/');
}
In components/_App/Header.js file:
const isRoot = user && user.role === 'root';
const isAdmin = user && user.role === 'admin';
const isRootOrAdmin = isRoot || isAdmin;
{isRootOrAdmin && (
<Link href='/create'>
<Menu.Item header active={isActive('/create')}>
<Icon name='add square' size='large' />
Create
</Menu.Item>
</Link>
)}
Also, a regular user does not have permission to delete a product. So the Delete Product button will be hidden
In components/Product/ProductAttributes.js file
const isRoot = user && user.role === 'root';
const isAdmin = user && user.role === 'admin';
const isRootOrAdmin = isRoot || isAdmin;
{isRootOrAdmin && (
<Fragment>
<Button
icon='trash alternate outline'
color='red'
content='Delete Product'
onClick={() => setModal(true)}
/>
<Modal open={modal} dimmer='blurring'>
...
)}
4. Logout User
In utils/auth.js file:
import cookie from 'js-cookie';
export function handleLogout() {
cookie.remove('token');
Router.push('/login');
}
In components/_App/Header.js file:
import { handleLogout } from '../../utils/auth';
<Menu.Item onClick={handleLogout} header>
<Icon name='sign out' size='large' />
Logout
</Menu.Item>
5. Universal Logout Using LocalStorage
When a user logs out of our application, we want to log them out everywhere, not just one browser window. We can perform a universal logout using localStorage
In utils/auth.js file:
export function handleLogout() {
cookie.remove('token');
window.localStorage.setItem('logout', Date.now());
Router.push('/login');
}
What this does is our custom App component(_app.js) is going to detect a change in localStorage
In pages/_app.js file:
import Router from 'next/router';
componentDidMount() {
window.addEventListener('storage', this.syncLogout);
}
syncLogout = (event) => {
if (event.key === 'logout') {
// console.log('Logged out from storage')
Router.push('/login');
}
};
1. Fetch User Cart
Let’s fetch the user’s cart in the database on the cart route. When the cart page loads, use getInitialProps function to make an api request to get cart data and display it on the cart page
In pages/cart.js file:
import { parseCookies } from 'nookies';
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
function Cart({ products }) {
// console.log(products)
return (
<Segment>
<CartItemList />
<CartSummary />
</Segment>
);
}
Cart.getInitialProps = async (ctx) => {
// Destructure token property from the returned cookies object
const { token } = parseCookies(ctx);
// First check to see if user is authenticated
// --if not, set products to an empty array and return early
if (!token) {
return { products: [] };
}
const url = `${baseUrl}/api/cart`;
const payload = { headers: { Authorization: token } };
const response = await axios.get(url, payload);
return { products: response.data };
};
export default Cart;
In pages/api/cart.js file:
import jwt from 'jsonwebtoken';
import connectDB from '../../utils/connectDb';
import Cart from '../../models/Cart';
connectDB();
export default async (req, res) => {
// Check if a token is provided with the request
if (!('authorization' in req.headers)) {
return res.status(401).send('No authorization token');
}
try {
// Verify the provided token
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
const cart = await Cart.findOne({ user: userId }).populate({
path: 'products.product',
model: 'Product'
});
// Send back just the products array from cart
res.status(200).json(cart.products);
} catch (error) {
console.error(error);
res.status(403).send('Please login again');
}
};
2. Add Products to Cart Functionality
Implement functionality that allows user to add a product to cart:
In components/Product/AddProductToCart.js file:
cookie.get()
methodimport { useState, useEffect } from 'react';
import { Input } from 'semantic-ui-react';
import { useRouter } from 'next/router';
import axios from 'axios';
import baseUrl from '../../utils/baseUrl';
import catchErrors from '../../utils/catchErrors';
import cookie from 'js-cookie';
function AddProductToCart({ productId, user }) {
const [quantity, setQuantity] = useState(1);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
useEffect(() => {
let timeout;
if (success) {
timeout = setTimeout(() => setSuccess(false), 3000);
}
return () => {
// This is a global function
clearTimeout(timeout);
};
}, [success]);
async function handleAddProductToCart() {
try {
setLoading(true);
const url = `${baseUrl}/api/cart`;
const payload = { quantity, productId };
// Get the token from cookie
const token = cookie.get('token');
// Provide the token as auth headers
const headers = { headers: { Authorization: token } };
await axios.put(url, payload, headers);
setSuccess(true);
} catch (error) {
catchErrors(error, window.alert);
} finally {
setLoading(false);
}
}
return (
<Input
loading={loading}
onChange={(event) => setQuantity(Number(event.target.value))}
type='number'
min='1'
placeholder='Quantity'
value={quantity}
action={
user && success
? {
color: 'blue',
content: 'Item Added!',
icon: 'plus cart',
disabled: true
}
: user
? {
color: 'orange',
content: 'Add to Cart',
icon: 'plus cart',
loading,
disabled: loading,
onClick: handleAddProductToCart
}
: {
color: 'blue',
content: 'Sign Up to Purchase',
icon: 'signup',
onClick: () => router.push('/signup')
}
}
/>
);
}
export default AddProductToCart;
Server-side: handle add product to cart route:
In pages/api/cart.js file:
import mongoose from 'mongoose';
const { ObjectId } = mongoose.Types;
export default async (req, res) => {
switch (req.method) {
case 'GET':
await handleGetRequest(req, res);
break;
case 'PUT':
await handlePutRequest(req, res);
break;
default:
res.status(405).send(`Method ${req.method} not allowed`);
break;
}
};
async function handlePutRequest(req, res) {
const { productId, quantity } = req.body;
if (!('authorization' in req.headers)) {
return res.status(401).send('No authorization token');
}
try {
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
// Get user cart based on userId
const cart = await Cart.findOne({ user: userId });
// Check if product already exists in cart
// Use mongoose's ObjectId() method to convert string productId to objectIds
// Returns true or false
const productExists = cart.products.some((doc) =>
ObjectId(productId).equals(doc.product)
);
// If so, increment quantity (by number provided to request)
// 1st arg is specifying what we want to update
// 2nd arg is how we want to update it
// In mongoDB $inc is the increment operator. The $ is the index in the array
// And then provide the path to the property we want to increment
if (productExists) {
await Cart.findOneAndUpdate(
{ _id: cart._id, 'products.product': productId },
{ $inc: { 'products.$.quantity': quantity } }
);
} else {
// If not, add new product with given quantity
// Use the $addToSet operator to ensure there won't be any duplicated product add
const newProduct = { quantity, product: productId };
await Cart.findOneAndUpdate(
{ _id: cart._id },
{ $addToSet: { products: newProduct } }
);
}
res.status(200).send('Cart updated');
} catch (error) {
console.error(error);
res.status(403).send('Please login again');
}
}
3. Style Cart Products
Now that users can add products to their cart, we want to display a summary of those added products in their cart route/page with a list
In pages/cart.js file:
<CartItemList user={user} products={products} />
In components/Cart/CartItemList.js file:
import { Header, Segment, Icon, Button, Item } from 'semantic-ui-react';
import { useRouter } from 'next/router';
function CartItemList({ products, user }) {
const router = useRouter();
function mapCartProductsToItems(products) {
return products.map((p) => ({
childKey: p.product._id,
header: (
<Item.Header
as='a'
onClick={() => router.push(`/product?_id=${p.product._id}`)}
>
{p.product.name}
</Item.Header>
),
image: p.product.mediaUrl,
meta: `${p.quantity} x $${p.product.price}`,
fluid: 'true',
extra: (
<Button
basic
icon='remove'
floated='right'
onClick={() => console.log(p.product._id)}
/>
)
}));
}
if (products.length === 0) {
return (
<Segment secondary color='teal' inverted textAlign='center'>
<Header icon>
<Icon name='shopping basket' />
No products in your cart. Add some!
</Header>
<div>
{user ? (
<Button onClick={() => router.push('/')} color='orange'>
View Products
</Button>
) : (
<Button onClick={() => router.push('/login')} color='blue'>
Login to Add Products
</Button>
)}
</div>
</Segment>
);
}
return <Item.Group divided items={mapCartProductsToItems(products)} />;
}
export default CartItemList;
4. Calculate Cart Total
In pages/cart.js file:
<CartSummary products={products} />
In components/Cart/CartSummary.js file:
import { Fragment, useState, useEffect } from 'react';
import { Segment, Button, Divider } from 'semantic-ui-react';
import calculateCartTotal from '../../utils/calculateCartTotal';
function CartSummary({ products }) {
const [isCartEmpty, setIsCartEmpty] = useState(false);
const [cartAmount, setCartAmount] = useState(0);
const [stripeAmount, setStripeAmount] = useState(0);
useEffect(() => {
const { cartTotal, stripeTotal } = calculateCartTotal(products);
setCartAmount(cartTotal);
setStripeAmount(stripeTotal);
setIsCartEmpty(products.length === 0);
}, [products]);
return (
<Fragment>
<Divider />
<Segment clearing size='large'>
<strong>Subtotal:</strong> ${cartAmount}
<Button
icon='cart'
disabled={isCartEmpty}
color='teal'
floated='right'
content='Checkout'
/>
</Segment>
</Fragment>
);
}
export default CartSummary;
In utils/calculateCartTotal.js file:
function calculateCartTotal(products) {
const total = products.reduce((accum, el) => {
accum += el.product.price * el.quantity;
return accum;
}, 0);
// Trick to remove any rounding errors, multiply by 100 then divide by 100
// To make sure it rounds to two decimal places
const cartTotal = ((total * 100) / 100).toFixed(2);
const stripeTotal = Number((total * 100).toFixed(2));
return { cartTotal, stripeTotal };
}
export default calculateCartTotal;
5. Removing Cart Products
To delete a product from cart, we need to make a delete request for our cart endpoint
In pages/cart.js file:
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
import cookie from 'js-cookie';
function Cart({ products, user }) {
const [cartProducts, setCartProducts] = useState(products);
async function handleRemoveFromCart(productId) {
const url = `${baseUrl}/api/cart`;
const token = cookie.get('token');
const payload = {
params: { productId },
headers: { Authorization: token }
};
const response = await axios.delete(url, payload);
setCartProducts(response.data);
}
return (
<Segment>
<CartItemList
handleRemoveFromCart={handleRemoveFromCart}
user={user}
products={cartProducts}
/>
<CartSummary products={cartProducts} />
</Segment>
);
}
In pages/api/cart.js file:
case 'DELETE':
await handleDeleteRequest(req, res);
break;
async function handleDeleteRequest(req, res) {
// Get productId from query string
const { productId } = req.query;
if (!('authorization' in req.headers)) {
return res.status(401).send('No authorization token');
}
try {
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
const cart = await Cart.findOneAndUpdate(
{ user: userId },
{ $pull: { products: { product: productId } } },
{ new: true }
).populate({
path: 'products.product',
model: 'Product'
});
res.status(200).json(cart.products);
} catch (error) {
console.error(error);
res.status(403).send('Please login again');
}
}
In components/Cart/CartItemList.js file:
onClick={() => handleRemoveFromCart(p.product._id)}
6. Checkout Customer Cart with Stripe Payment
Install react-stripe-checkout and stripe: npm i react-stripe-checkout stripe
Now we want users to be able to checkout their cart. They will provide their email, shipping info, and payment info. Once that is done, Stripe is going to approve their purchase and we can show a success message and clear out their cart
Setup Stripe account:
STRIPE_SECRET_KEY
env variable in next.config.js fileClient-side: make an api request to checkout cart with Stripe:
In pages/cart.js file:
import cookie from 'js-cookie';
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
import catchErrors from '../utils/catchErrors';
function Cart({ products, user }) {
const [cartProducts, setCartProducts] = useState(products);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleCheckout(paymentData) {
try {
setLoading(true);
const url = `${baseUrl}/api/checkout`;
const token = cookie.get('token');
const payload = { paymentData };
const headers = { headers: { Authorization: token } };
await axios.post(url, payload, headers);
setSuccess(true);
} catch (error) {
catchErrors(error, window.alert);
} finally {
setLoading(false);
}
}
return (
<Segment loading={loading}>
<CartItemList
handleRemoveFromCart={handleRemoveFromCart}
user={user}
products={cartProducts}
success={success}
/>
<CartSummary
products={cartProducts}
handleCheckout={handleCheckout}
success={success}
/>
</Segment>
);
}
In components/Cart/CartSummary.js file:
token={handleCheckout}
import StripeCheckout from 'react-stripe-checkout';
<StripeCheckout
name='Furniture Boutique'
amount={stripeAmount}
image={products.length > 0 ? products[0].product.mediaUrl : ''}
currency='USD'
shippingAddress={true}
billingAddress={true}
zipCode={true}
stripeKey='stripe-publishable-key'
token={handleCheckout}
triggerEvent='onClick'
>
<Button
icon='cart'
disabled={isCartEmpty || success}
color='teal'
floated='right'
content='Checkout'
/>
</StripeCheckout>
In components/Cart/CartItemList.js file:
if (success) {
return (
<Message
success
header='Success!'
content='Your order and payment has been accepted'
icon='star outline'
/>
);
}
Server-side: create cart checkout route handler:
In pages/api/checkout.js file:
import Stripe from 'stripe';
import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import Cart from '../../models/Cart';
import Order from '../../models/Order';
import calculateCartTotal from '../../utils/calculateCartTotal';
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
export default async (req, res) => {
const { paymentData } = req.body;
try {
// 1) Verify and get user id from token
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
// 2) Find cart based on user id, populate it
const cart = await Cart.findOne({ user: userId }).populate({
path: 'products.product',
model: 'Product'
});
// 3) Calculate cart totals again from cart products
const { cartTotal, stripeTotal } = calculateCartTotal(cart.products);
// 4) Get email from payment data, see if email linked with existing Stripe customer
const prevCustomer = await stripe.customers.list({
email: paymentData.email,
limit: 1
});
const isExistingCustomer = prevCustomer.data.length > 0;
// 5) If not existing customer, create them based on their email
let newCustomer;
if (!isExistingCustomer) {
newCustomer = await stripe.customers.create({
email: paymentData.email,
source: paymentData.id
});
}
const customer =
(isExistingCustomer && prevCustomer.data[0].id) || newCustomer.id;
// 6) Create charge with total, send receipt email
const charge = await stripe.charges.create(
{
currency: 'usd',
amount: stripeTotal,
receipt_email: paymentData.email,
customer,
description: `Checkout | ${paymentData.email} | ${paymentData.id}`
},
{
idempotencyKey: uuidv4()
}
);
// 7) Add order data to database
await new Order({
user: userId,
email: paymentData.email,
total: cartTotal,
products: cart.products
}).save();
// 8) Clear products in cart
await Cart.findOneAndUpdate({ _id: cart._id }, { $set: { products: [] } });
// 9) Send back success (200) response
res.status(200).send('Checkout successful');
} catch (error) {
console.error(error);
res.status(500).send('Error processing charge');
}
};
Define Order Model:
The Order model is similar to the Cart model except it also includes the email and total fields and a timestamps for when the order was created
In models/Order.js file:
import mongoose from 'mongoose';
const { ObjectId, Number } = mongoose.Schema.Types;
const OrderSchema = new mongoose.Schema(
{
user: {
type: ObjectId,
ref: 'User' //referencing the User model
},
products: [
{
quantity: {
type: Number,
default: 1
},
product: {
type: ObjectId,
ref: 'Product' //referencing the Product model
}
}
],
email: {
type: String,
required: true
},
total: {
type: Number,
required: true
}
},
{
timestamps: true
}
);
export default mongoose.models.Order || mongoose.model('Order', OrderSchema);
1. Add Pagination for Product List
Let’s divide up our product list into multiple pages instead of having them all in a single page
Client-side: make request to /products endpoint with page query params
In pages/index.js file:
import ProductPagination from '../components/Index/ProductPagination';
// Destructure products and totalPages props from getServerSideProps function
function Home({ products, totalPages }) {
return (
<Fragment>
<ProductList products={products} />
<ProductPagination totalPages={totalPages} />
</Fragment>
);
}
export async function getServerSideProps(ctx) {
// console.log(ctx.query)
// Check to see if page query is available
const page = ctx.query.page ? ctx.query.page : '1';
// size is the number of products on a page
const size = 9;
// fetch data on server
const url = `${baseUrl}/api/products`;
// params is query string params
const payload = { params: { page, size } };
const response = await axios.get(url, payload);
// The return response.data object contains products array and totalPages
// note: this object will be merged with existing props
return { props: response.data };
}
In components/Index/ProductPagination.js file:
import { useRouter } from 'next/router';
import { Container, Pagination } from 'semantic-ui-react';
function ProductPagination({ totalPages }) {
const router = useRouter();
return (
<Container textAlign='center' style={{ margin: '2em' }}>
<Pagination
defaultActivePage={1}
totalPages={totalPages}
onPageChange={(event, data) => {
// console.log(data)
data.activePage === 1
? router.push('/')
: router.push(`/?page=${data.activePage}`);
}}
/>
</Container>
);
}
export default ProductPagination;
Server-side: Create route handler to products with page query endpoint:
In pages/api/products.js file:
export default async (req, res) => {
const { page, size } = req.query;
// Convert query string values to numbers
const pageNum = Number(page);
const pageSize = Number(size);
let products = [];
const totalDocs = await Product.countDocuments();
const totalPages = Math.ceil(totalDocs / pageSize);
if (pageNum === 1) {
// limit the number of products getting back from db by pageSize
products = await Product.find().limit(pageSize);
} else {
const skips = pageSize * (pageNum - 1);
products = await Product.find().skip(skips).limit(pageSize);
}
// const products = await Product.find();
res.status(200).json({ products, totalPages });
};
2. Create Account Header, Order History
The account route/page displays the user’s account header (their name, email, joined date), order history, and user permissions
Client-side: make request to /orders endpoint to get orders data:
In pages/account.js file:
import { Fragment } from 'react';
import AccountHeader from '../components/Account/AccountHeader';
import AccountOrders from '../components/Account/AccountOrders';
import axios from 'axios';
import baseUrl from '../utils/baseUrl';
import { parseCookies } from 'nookies';
function Account({ user, orders }) {
return (
<Fragment>
<AccountHeader {...user} />
<AccountOrders orders={orders} />
</Fragment>
);
}
Account.getInitialProps = async (ctx) => {
const { token } = parseCookies(ctx);
if (!token) {
return { orders: [] };
}
const payload = { headers: { Authorization: token } };
const url = `${baseUrl}/api/orders`;
const response = await axios.get(url, payload);
// The response.data object contains orders object props
return response.data;
};
export default Account;
In components/Account/AccountHeader.js file:
import { Header, Icon, Segment, Label } from 'semantic-ui-react';
function AccountHeader({ role, name, email, createdAt }) {
return (
<Segment secondary inverted color='violet'>
<Label
color='teal'
size='large'
ribbon
icon='privacy'
content={role}
style={{ textTransform: 'capitalize' }}
/>
<Header inverted textAlign='center' as='h1' icon>
<Icon name='user' />
{name}
<Header.Subheader>{email}</Header.Subheader>
<Header.Subheader>Joined {createdAt}</Header.Subheader>
</Header>
</Segment>
);
}
export default AccountHeader;
In components/Account/AccountOrders.js file:
import { Fragment } from 'react';
import { Header, Accordion, Label, Segment, Icon, Button, List, Image } from 'semantic-ui-react';
import { useRouter } from 'next/router';
function AccountOrders({ orders }) {
const router = useRouter();
function mapOrdersToPanels(orders) {
return orders.map((order) => ({
key: order._id,
title: {
content: <Label color='blue' content={order.createdAt} />
},
content: {
content: (
<Fragment>
<List.Header as='h3'>
Total: ${order.total}
<Label
content={order.email}
icon='mail'
basic
horizontal
style={{ marginLeft: '1em' }}
/>
</List.Header>
<List>
{order.products.map((p) => (
<List.Item key={p._id}>
<Image avatar src={p.product.mediaUrl} />
<List.Content>
<List.Header>{p.product.name}</List.Header>
<List.Description>
{p.quantity} x ${p.product.price}
</List.Description>
</List.Content>
<List.Content floated='right'>
<Label tag color='red' size='tiny'>
{p.product.sku}
</Label>
</List.Content>
</List.Item>
))}
</List>
</Fragment>
)
}
}));
}
return (
<Fragment>
<Header as='h2'>
<Icon name='folder open' />
Order History
</Header>
{orders.length === 0 ? (
<Segment inverted tertiary color='grey' textAlign='center'>
<Header icon>
<Icon name='copy outline' />
No past orders
</Header>
<div>
<Button onClick={() => router.push('/')} color='orange'>
View Products
</Button>
</div>
</Segment>
) : (
<Accordion
fluid
styled
exclusive={false}
panels={mapOrdersToPanels(orders)}
/>
)}
</Fragment>
);
}
export default AccountOrders;
Server-side: Create route handler for orders request:
In pages/api/orders.js file:
import jwt from 'jsonwebtoken';
import Order from '../../models/Order';
import connectDb from '../../utils/connectDb';
connectDb();
export default async (req, res) => {
try {
// jwt.verify() method verifies the token
// 1st arg is the provided token
// 2nd arg is the jwt secret which we use to sign the token
// what's returned is an object. Destructure the userId property from it
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
const orders = await Order.find({ user: userId }).populate({
path: 'products.product',
model: 'Product'
});
// The .find() method returns an orders array. But we want to return orders object back to client
res.status(203).json({ orders });
} catch (error) {
console.error(error);
res.status(403).send('Please login again');
}
};
3. Create Root User, Add User Permissions
Up until this point we haven’t given user other roles other than the ‘user’ role. This is the default value they’re given when they signup
A root user can manage admin and regular users roles. They have access to the User Permissions panel to change users’ roles in the database
Admin users can create and delete products within our application
Regular users don’t have permission to create or delete products. They can add or delete products in their cart, make purchase and view their account
Client-side: make request to /users endpoint to get users data:
In pages/account.js file:
import AccountPermissions from '../components/Account/AccountPermissions';
{user.role === 'root' && <AccountPermissions currentUserId={user._id} />}
In components/Account/AccountPermission.js file:
import { useState, useEffect } from 'react';
import { Header, Checkbox, Table, Icon } from 'semantic-ui-react';
import axios from 'axios';
import cookie from 'js-cookie';
import baseUrl from '../../utils/baseUrl';
function AccountPermissions() {
const [users, setUsers] = useState([]);
useEffect(() => {
getUsers();
}, []);
async function getUsers() {
const url = `${baseUrl}/api/users`;
const token = cookie.get('token');
const payload = { headers: { Authorization: token } };
const response = await axios.get(url, payload);
// console.log(response.data)
setUsers(response.data);
}
return (
<div style={{ margin: '2em 0' }}>
<Header as='h2'>
<Icon name='settings' />
User Permissions
</Header>
<Table compact celled definition>
<Table.Header>
<Table.Row>
<Table.HeaderCell />
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Email</Table.HeaderCell>
<Table.HeaderCell>Joined</Table.HeaderCell>
<Table.HeaderCell>Updated</Table.HeaderCell>
<Table.HeaderCell>Role</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{users.map((user) => (
<UserPermission key={user._id} user={user} />
))}
</Table.Body>
</Table>
</div>
);
}
function UserPermission({ user }) {
return (
<Table.Row>
<Table.Cell collapsing>
<Checkbox toggle />
</Table.Cell>
<Table.Cell>{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>{user.createdAt}</Table.Cell>
<Table.Cell>{user.updatedAt}</Table.Cell>
<Table.Cell>{user.role}</Table.Cell>
</Table.Row>
);
}
export default AccountPermissions;
Server-side: create route handler to users request:
In pages/api/users.js file:
$ne
operator to exclude the root userimport jwt from 'jsonwebtoken';
import User from '../../models/User';
import connectDb from '../../utils/connectDb';
connectDb();
export default async (req, res) => {
try {
// jwt.verify() method verifies the token
// 1st arg is the provided token
// 2nd arg is the jwt secret which we use to sign the token
// what's returned is an object. Destructure the userId property from it
const { userId } = jwt.verify(
req.headers.authorization,
process.env.JWT_SECRET
);
// Get every user in the users collection, EXCEPT for our self - the root user
// $ne is not equal to operator
// Filter out the user _id that is not equal to the userId
const users = await User.find({ _id: { $ne: userId } });
res.status(200).json(users);
} catch (error) {
console.error(error);
res.status(403).send('Please login again');
}
};
4. Change User Roles, Permissions
Next we want to enable the root user to dynamically change the users roles by toggling the checkbox next to the user
To do this, we want to keep track of a user state in the UserPermission component. Whenever the user’s role changes on the client-side(the root uses makes the change), we want to make a request to an endpoint to change the user’s role in the database
In components/Account/AccountPermission.js file and inside the UserPermission component:
checked
property and set it to admin state, where the checkbox is checked when admin state is truefunction UserPermission({ user }) {
const [admin, setAdmin] = useState(user.role === 'admin');
function handleChangePermission() {
setAdmin((prevState) => !prevState);
}
return (
<Table.Row>
<Table.Cell collapsing>
<Checkbox checked={admin} toggle onChange={handleChangePermission} />
</Table.Cell>
<Table.Cell>{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>{user.createdAt}</Table.Cell>
<Table.Cell>{user.updatedAt}</Table.Cell>
<Table.Cell>{admin ? 'admin' : 'user'}</Table.Cell>
</Table.Row>
);
}
Once the user’s role is changed (by updating the admin state), we want to make a request to backend to update the user’s role in the database
Client-side: make request to /account endpoint to update user’s role:
In components/Account/AccountPermission.js file and in UserPermission component:
function UserPermission({ user }) {
const [admin, setAdmin] = useState(user.role === 'admin');
const isFirstRun = useRef(true);
// We only want useEffect to run when admin state changes, not when the component first mounts
useEffect(() => {
// The current property is whatever value we initialize when calling useRef() hook
if (isFirstRun.current) {
isFirstRun.current = false;
return;
}
updatePermission();
}, [admin]);
function handleChangePermission() {
setAdmin((prevState) => !prevState);
}
// Make request to update user's role based on user id in the db
async function updatePermission() {
const url = `${baseUrl}/api/account`;
const payload = { _id: user._id, role: admin ? 'admin' : 'user' };
// Use put method to update the db
await axios.put(url, payload);
}
return ( ... )
}
Server-side: create a route to handle account update request:
In pages/api/account.js file:
export default async (req, res) => {
switch (req.method) {
case 'GET':
await handleGetRequest(req, res);
break;
case 'PUT':
await handlePutRequest(req, res);
break;
default:
res.status(405).send(`Method ${req.method} not allowed`);
break;
}
};
async function handlePutRequest(req, res) {
const { _id, role } = req.body;
// Find user by its id
// Update the role field with the role data
await User.findOneAndUpdate({ _id }, { role });
res.status(203).send('User updated');
}
1. Sorting in Mongoose, MongoDB
In MongoDB, we can sort a set of documents after a given query (such as the find method) by chaining on the .sort() method
Sort order history by descending order with most recent order listed first
Sort the users in User Permissions panel by their roles, ascending order. Admin users listed first
In pages/api/orders.js file:
const orders = await Order.find({ user: userId })
.sort({ createdAt: 'desc' })
.populate({
path: 'products.product',
model: 'Product'
});
In pages/api/users.js file:
const users = await User.find({ _id: { $ne: userId } }).sort({ role: 'asc' });
2. Formatting Dates
Let’s format all of the dates in a user-friendly readable format. We can achieve this by writing a utility function and pass in our dates to the function
In utils/formatDate.js file:
export default function formatDate(date) {
return new Date(date).toLocaleDateString('en-US');
}
Import and use the formatDate function on our dates in AccountHeader.js, AccountOrders.js, and AccountPermissions.js components
<Table.Cell>{formatDate(user.createdAt)}</Table.Cell>
3. Cascade Delete upon Document Removal
A cascade delete is when you delete a given document, you want to remove all the places where you have references
For example, when we delete a product, we want to remove the product in all carts
In pages/api/product.js file:
$pull
operator to pull the product by id in the products arrayasync function handleDeleteRequest(req, res) {
try {
const { _id } = req.query;
// 1) Delete product by id
await Product.findOneAndDelete({ _id });
// 2) Remove product from all carts, referenced as 'product'
await Cart.updateMany(
// The reference we want to remove from all cart documents
{ 'products.product': _id },
// The pull operator pulls the product by id from products array
{ $pull: { products: { product: _id } } }
);
// status code 204 means success and no content is sent back
res.status(204).json({});
} catch (error) {
console.error(error);
res.status(500).send('Error deleting product');
}
}
Setup a vercel account:
sudo npm i -g vercel
vercel login
Configure the vercel.json file:
In vercel.json file:
{
"env": {
"MONGO_SRV": "<insert-mongo-srv>",
"JWT_SECRET": "<insert-jwt-secret>",
"CLOUDINARY_URL": "<insert-cloudinary-url>",
"STRIPE_SECRET_KEY": "<insert-stripe-secret-key>"
}
}
Configure production base URL:
In utils/baseUrl.js file:
const baseUrl =
process.env.NODE_ENV === 'production'
? 'https://furnitureboutique.vercel.app'
: 'http://localhost:3000';
export default baseUrl;
Deploying our app to vercel:
vercel
To deploy to production:
vercel --prod
Link to Furniture Boutique app:
Create a project on Heroku:
heroku login
heroku create furnitureboutique
Configure production base URL:
In utils/baseUrl.js file:
const baseUrl =
process.env.NODE_ENV === 'production'
? 'https://furnitureboutique.herokuapp.com'
: 'http://localhost:3000';
export default baseUrl;
Configure run script in package.json file:
"scripts": {
"dev": "next",
"start": "next start -p $PORT",
"build": "next build"
}
Deploy to Heroku:
git add .
git commit -m "Initial commit"
git push heroku main
heroku restart
Commit changes to Heroku repo:
git remote -v
git push heroku main
Author: sungnga
Demo: https://furnitureboutique.herokuapp.com/
Source Code: https://github.com/sungnga/nextjs-furniture-boutique-app
#react #nextjs #javascript #next