The codebase for each step can be found in the commit link
In Header.js file:
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 } ```
In Header.js file:
Router.onRouteChangeStart = () => nProgress.start(); Router.onRouteChangeComplete = () => nProgress.done(); Router.onRouteChangeError = () => nProgress.done(); ```
In pages/index.js file:
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 home; }
export default Home; ```
In pages/api/products.js file:
export default (req, res) => { res.status(200).json(products); }; ```
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;
In utils/connectDb.js file:
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:
For example, import and execute the function in pages/api/products.js file
// Execute the connectDB function to connect to MongoDB connectDB(); ```
Model Product data:
In models/Product.js file:
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:
// Execute the connectDB function to connect to MongoDB connectDB();
export default async (req, res) => { const products = await Product.find(); res.status(200).json(products); }; ```
The pages/index.js file renders the ProductList.js component
function Home({ products }) { // console.log(products); return ; } ```
In components/Index/ProductList.js file
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; ```
In pages/product.js file:
function Product({ product }) { console.log(product); return
product
; }
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; ```
In pages/api/product.js file:
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); }; ```
// Spreading the product object as props using the object spread operator <Fragment> <ProductSummary {...product} /> <ProductAttributes {...product} /> </Fragment>
In components/Product/ProductSummary.js file:
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:
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;
In utils/baseUrl.js file:
const baseUrl = process.env.NODE_ENV === 'production' ? 'https://deployment-url.now.sh' : 'http://localhost:3000'; export default baseUrl;
In pages/index.js and pages/product.js files:
const url = ${baseUrl}/api/products; ```
In components/Product/ProductAttributes.js file:
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; ```
In pages/api/product.js file:
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({}); } ```
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;
In pages/create.js file:
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:
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); } ```
Prevent users from submitting empty product input fields:
In pages/create.js file:
// 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]);
```
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;
In pages/create.js file:
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
```
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'); } }
In pages/cart.js file:
function Cart() { return (
<Segment> <CartItemList /> <CartSummary /> </Segment>
); }
export default Cart; ```
In components/Cart/CartItemList.js file:
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:
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; ```
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... )
In pages/signup.js file:
// 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) ```
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);
In pages/api/signup.js file:
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');
} }; ```
In utils/auth.js file:
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:
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);
} } ```
In pages/api/signup.js file:
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'); } ```
In pages/login.js file:
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); ```
In pages/api/login.js file:
// 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');
} }; ```
In pages/login.js file:
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);
} } ```
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);
In pages/api/signup.js file:
const cart = await new Cart({ user: newUser._id }); cart.save(); ```
In pages/_app.js file:
// 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 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> );
} } ```
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); } }
In pages/api/account.js file:
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 page/_app.js file:
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') } ```
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:
{isRootOrAdmin && (
<Menu.Item header active={isActive('/create')}> <Icon name='add square' size='large' /> Create </Menu.Item>
)} ```
In components/Product/ProductAttributes.js file
{isRootOrAdmin && (
<Button icon='trash alternate outline' color='red' content='Delete Product' onClick={() => setModal(true)} /> <Modal open={modal} dimmer='blurring'> ...
)} ```
In utils/auth.js file:
export function handleLogout() { cookie.remove('token'); Router.push('/login'); } ```
In components/_App/Header.js file:
Logout ```
export function handleLogout() { cookie.remove('token'); window.localStorage.setItem('logout', Date.now()); Router.push('/login'); }
In pages/_app.js file:
Use componentDidMount function to listen for event changes in localStorage
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'); } };
In pages/cart.js file:
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:
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');
} }; ```
In components/Product/AddProductToCart.js file:
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; ```
In pages/api/cart.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 { 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');
} } ```
In components/Cart/CartItemList.js file:
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 ; }
export default CartItemList; ```
In components/Cart/CartSummary.js file:
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:
Write a calculateCartTotal helper function that adds up the total price of products in cart ```js 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; ```
In pages/cart.js file:
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:
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 pages/cart.js file:
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:
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'
/> ```
if (success) { return ( <Message success header='Success!' content='Your order and payment has been accepted' icon='star outline' /> ); }
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'); } };
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);
In pages/index.js file:
// 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:
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; ```
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 }); };
In pages/account.js file:
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:
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:
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; ```
In pages/api/orders.js file:
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');
} }; ```
In pages/account.js file:
{user.role === 'root' && } ```
In components/Account/AccountPermission.js file:
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; ```
In pages/api/users.js file:
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');
} }; ```
In components/Account/AccountPermission.js file and inside the UserPermission component:
Now when the checkbox is clicked it should toggle the user's role between admin and user
function 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> ); }
In components/Account/AccountPermission.js file and in UserPermission component:
Write an updatePermission function that makes a request to /account endpoint to update the user's role based on user id
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 ( ... ) }
In pages/api/account.js file:
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'); } ```
const orders = await Order.find({ user: userId }) .sort({ createdAt: 'desc' }) .populate({ path: 'products.product', model: 'Product' });
const users = await User.find({ _id: { $ne: userId } }).sort({ role: 'asc' });
export default function formatDate(date) { return new Date(date).toLocaleDateString('en-US'); }
async 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'); } }
{ "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:
export default baseUrl; ```
Configure production base URL:
In utils/baseUrl.js file:
export default baseUrl; ```
"scripts": { "dev": "next", "start": "next start -p $PORT", "build": "next build" }
Deploy to Heroku:
Commit changes to Heroku repo: