Tutorial updated by Fredrick Emmanuel and Paul Bratslavsky
This tutorial is part of the « Cooking a Deliveroo clone with Next.js (React), GraphQL, Strapi and Stripe » tutorial series. Table of contents
Note: The source code is available on GitHub here
These dishes look so tasty! What if you could add some of them to a shopping cart?
That is exactly what we are going to do. We going to add the following card component and allow users add items to their cart.
First things first, let's update our context provider to allow us to save our items in the cart.
To keep track of the items added to the cart across pages you will use the React Context API.
This will prevent having to prop drill the items multiple levels deep. The context will allow you to manage the state of items that will be re-used on the checkout page.
The only thing React Context will not take care of for you is preserving items through a page refresh. For that, we will save our cart items to a cookie.
You can also save it to a DB and restore them, but that is not what we will do here today.
Update the code in your AppContext.js
file found in our context
folder with the code below.
1import { useState, createContext, useContext, useEffect } from "react";
2import Cookie from "js-cookie";
3import { gql } from "@apollo/client";
4import { client } from "@/pages/_app.js";
5
6const AppContext = createContext();
7
8export const AppProvider = ({ children }) => {
9 const cartCookie =
10 Cookie.get("cart") !== "undefined" ? Cookie.get("cart") : null;
11
12 const [user, setUser] = useState(null);
13 const [showCart, setShowCart] = useState(true);
14 const [cart, setCart] = useState(
15 cartCookie ? JSON.parse(cartCookie) : { items: [], total: 0 }
16 );
17
18 useEffect(() => {
19 const fetchData = async () => {
20 const userData = await getUser();
21 setUser(userData);
22 };
23 fetchData();
24 }, []);
25
26 useEffect(() => {
27 Cookie.set("cart", JSON.stringify(cart));
28 }, [cart]);
29
30 const addItem = (item) => {
31 let newItem = cart.items.find((i) => i.id === item.id);
32 if (!newItem) {
33 const newItem = {
34 quantity: 1,
35 ...item,
36 };
37 setCart((prevCart) => ({
38 items: [...prevCart.items, newItem],
39 total: prevCart.total + item.attributes.priceInCents,
40 }));
41 } else {
42 setCart((prevCart) => ({
43 items: prevCart.items.map((i) =>
44 i.id === newItem.id ? { ...i, quantity: i.quantity + 1 } : i
45 ),
46 total: prevCart.total + item.attributes.priceInCents,
47 }));
48 }
49 };
50
51 const removeItem = (item) => {
52 let newItem = cart.items.find((i) => i.id === item.id);
53 if (newItem.quantity > 1) {
54 setCart((prevCart) => ({
55 items: prevCart.items.map((i) =>
56 i.id === newItem.id ? { ...i, quantity: i.quantity - 1 } : i
57 ),
58 total: prevCart.total - item.attributes.priceInCents,
59 }));
60 } else {
61 setCart((prevCart) => ({
62 items: prevCart.items.filter((i) => i.id !== item.id),
63 total: prevCart.total - item.attributes.priceInCents,
64 }));
65 }
66 };
67
68 const resetCart = () => {
69 setCart({ items: [], total: 0 });
70 };
71
72 return (
73 <AppContext.Provider
74 value={{
75 user,
76 setUser,
77 cart,
78 addItem,
79 removeItem,
80 resetCart,
81 showCart,
82 setShowCart,
83 }}
84 >
85 {children}
86 </AppContext.Provider>
87 );
88};
89
90const getUser = async () => {
91 const token = Cookie.get("token");
92 if (!token) return null;
93 const { data } = await client.query({
94 query: gql`
95 query {
96 me {
97 id
98 email
99 username
100 }
101 }
102 `,
103 context: {
104 headers: {
105 Authorization: `Bearer ${token}`,
106 },
107 },
108 });
109 return data.me;
110};
111
112export const useAppContext = () => {
113 const context = useContext(AppContext);
114 if (context === undefined)
115 throw new Error("useAppContext must be used within an AppProvider");
116 return context;
117};
This will allow us to save and remove our cart to state via addItem
and removeItem
function.
Now that we updated our context and added new cart methods, lets navigate to pages/restaurants/[id].jsx
and add the following changes.
1 const { addItem, setShowCart } = useAppContext();
2
3 function handleAddItem() {
4 addItem(data);
5 setShowCart(true);
6 }
The final code should look as follows:
1import { gql, useQuery } from "@apollo/client";
2import { centsToDollars } from "@/utils/centsToDollars";
3import { useRouter } from "next/router";
4import { useAppContext } from "@/context/AppContext";
5
6import Image from "next/image";
7import Loader from '@/components/Loader';
8
9const GET_RESTAURANT_DISHES = gql`
10 query ($id: ID!) {
11 restaurant(id: $id) {
12 data {
13 id
14 attributes {
15 name
16 dishes {
17 data {
18 id
19 attributes {
20 name
21 description
22 priceInCents
23 image {
24 data {
25 attributes {
26 url
27 }
28 }
29 }
30 }
31 }
32 }
33 }
34 }
35 }
36 }
37`;
38
39function DishCard({ data }) {
40 const { addItem, setShowCart } = useAppContext();
41
42 function handleAddItem() {
43 addItem(data);
44 setShowCart(true);
45 }
46
47 return (
48 <div className="w-full md:w-1/2 lg:w-1/3 p-4">
49
50 <div className="h-full bg-gray-100 rounded-2xl">
51 <Image
52 className="w-full rounded-2xl"
53 height={300}
54 width={300}
55 src={`${process.env.STRAPI_URL || "http://localhost:1337"}${
56 data.attributes.image.data.attributes.url
57 }`}
58 alt=""
59 />
60 <div className="p-8">
61 <div className="group inline-block mb-4" href="#">
62 <h3 className="font-heading text-xl text-gray-900 hover:text-gray-700 group-hover:underline font-black">
63 {data.attributes.name}
64 </h3>
65 <h2>${centsToDollars(data.attributes.priceInCents)}</h2>
66 </div>
67 <p className="text-sm text-gray-500 font-bold">
68 {data.attributes.description}
69 </p>
70 <div className="flex flex-wrap md:justify-center -m-2">
71 <div className="w-full md:w-auto p-2 my-6">
72 <button
73 className="block w-full px-12 py-3.5 text-lg text-center text-white font-bold bg-gray-900 hover:bg-gray-800 focus:ring-4 focus:ring-gray-600 rounded-full"
74 onClick={handleAddItem}
75 >
76 + Add to Cart
77 </button>
78 </div>
79 </div>
80 </div>
81 </div>
82 </div>
83 );
84}
85
86export default function Restaurant() {
87 const router = useRouter();
88 const { loading, error, data } = useQuery(GET_RESTAURANT_DISHES, {
89 variables: { id: router.query.id },
90 });
91
92 if (error) return "Error Loading Dishes";
93 if (loading) return <Loader />;
94 if (data.restaurant.data.attributes.dishes.data.length) {
95 const { restaurant } = data;
96
97 return (
98 <div className='py-6'>
99 <h1 className="text-4xl font-bold text-green-600">
100 {restaurant.data.attributes.name}
101 </h1>
102 <div className="py-16 px-8 bg-white rounded-3xl">
103 <div className="max-w-7xl mx-auto">
104 <div className="flex flex-wrap -m-4 mb-6">
105 {restaurant.data.attributes.dishes.data.map((res) => {
106 return <DishCard key={res.id} data={res} />;
107 })}
108 </div>
109 </div>
110 </div>
111 </div>
112 );
113 } else {
114 return <h1>No Dishes Found</h1>;
115 }
116}
Now let's create our cart component.
Create a new file in the components
folder named Cart.jsx
and add the following code.
Path: frontend/components/Cart.jsx
1import { useAppContext } from "@/context/AppContext";
2import { useRouter } from "next/router";
3import { centsToDollars } from "@/utils/centsToDollars";
4
5function CartItem({ data }) {
6 const { addItem, removeItem } = useAppContext();
7 const { quantity, attributes } = data;
8
9 return (
10 <div className="p-6 flex flex-wrap justify-between border-b border-blueGray-800">
11 <div className="w-2/4">
12 <div className="flex flex-col h-full">
13 <h6 className="font-bold text-white mb-1">{attributes.name}</h6>
14 <span className="block pb-4 mb-auto font-medium text-gray-400">
15 {quantity} x ${centsToDollars(attributes.priceInCents)}
16 </span>
17 </div>
18 </div>
19 <div className="w-1/4">
20 <div className="flex flex-col items-end h-full">
21 <div className="flex justify-between">
22 <button
23 className="mr-2 inline-block mb-auto font-medium text-sm text-gray-400 hover:text-gray-200"
24 onClick={() => removeItem(data)}
25 >
26 Remove
27 </button>
28 <button
29 className="inline-block mb-auto font-medium text-sm text-gray-400 hover:text-gray-200"
30 onClick={() => addItem(data)}
31 >
32 Add
33 </button>
34 </div>
35 <span className="block mt-2 text-sm font-bold text-white">
36 ${centsToDollars(attributes.priceInCents * quantity)}
37 </span>
38 </div>
39 </div>
40 </div>
41 );
42}
43
44export default function Cart() {
45 const router = useRouter();
46 const { user, cart, showCart, setShowCart } = useAppContext();
47 const total = cart.total;
48 const displayTotal = Math.abs(total);
49
50 function loginRedirect() {
51 router.push("/login");
52 }
53
54 function cartRedirect() {
55 setShowCart(false);
56 router.push("/checkout");
57 }
58
59 return (
60 <section className="fixed right-20 top-[242px]">
61 <div className="relative">
62 <button
63 onClick={() => setShowCart((prevState) => !prevState)}
64 className="absolute right-0 z-10 bg-green-500 text-white p-3 rounded-full hover:bg-yellow-500 items-center"
65 >
66 <svg
67 width="24"
68 height="24"
69 viewBox="0 0 16 18"
70 fill="none"
71 xmlns="http://www.w3.org/2000/svg"
72 >
73 <path
74 d="M11.3334 8.16667V4.83333C11.3334 2.99238 9.84099 1.5 8.00004 1.5C6.15909 1.5 4.66671 2.99238 4.66671 4.83333V8.16667M2.16671 6.5H13.8334L14.6667 16.5H1.33337L2.16671 6.5Z"
75 stroke="currentColor"
76 strokeWidth="1.5"
77 strokeLinecap="round"
78 strokeLinejoin="round"
79 ></path>
80 </svg>
81 </button>
82 {showCart && (
83 <div className="rounded-3xl co bg-gray-800">
84 <div className="max-w-lg pt-6 pb-8 px-8 mx-auto">
85 <div className="flex mb-10 items-center justify-between">
86 <h6 className="font-bold text-2xl text-white mb-0">
87 Your Cart
88 </h6>
89 </div>
90
91 <div>
92 {cart.items
93 ? cart.items.map((item, index) => {
94 if (item.quantity > 0) {
95 return <CartItem key={index} data={item} />;
96 }
97 })
98 : null}
99 </div>
100 <div className="p-6">
101 <div className="flex mb-6 content-center justify-between">
102 <span className="font-bold text-white">Order total</span>
103 <span className="text-sm font-bold text-white">
104 ${centsToDollars(displayTotal)}
105 </span>
106 </div>
107 <button
108 onClick={() => (user ? cartRedirect() : loginRedirect())}
109 className="inline-block w-full px-6 py-3 text-center font-bold text-white bg-green-500 hover:bg-green-600 transition duration-200 rounded-full"
110 >
111 {user ? "Continue To Pay" : "Login to Order"}
112 </button>
113 </div>
114 </div>
115 </div>
116 )}
117 </div>
118 </section>
119 );
120}
Now let's connect all these things together.
Update the /components/Layout.js
file to use our newly created Cart component.
Path: /components/Layout.js
We are using dynamic
import to deffer hydration you can read more on this here
This was necessary to avoid hydration miss-match error
. Late in the post I will show another way to avoid this error.
You can read about this error here
1import dynamic from "next/dynamic";
2import { useRouter } from "next/router";
3import { useAppContext } from "@/context/AppContext";
4import Cookie from "js-cookie";
5const Cart = dynamic(() => import("@/components/Cart"), { ssr: false });
6
7import Head from "next/head";
8import Link from "next/link";
9
10function Navigation() {
11 const { user, setUser } = useAppContext();
12 const router = useRouter();
13
14 function handleLogout() {
15 setUser(null);
16 Cookie.remove("token");
17 router.push("/");
18 }
19
20 return (
21 <header className="bg-green-800">
22 <nav className="flex justify-between p-6 px-4">
23 <div className="flex justify-between items-center w-full mx-16">
24 <div className="xl:w-1/3">
25 <Link
26 className="block text-2xl max-w-max text-white font-medium"
27 href="/"
28 >
29 Food Order App
30 </Link>
31 </div>
32
33 <div className="xl:block xl:w-1/3">
34 <div className="flex items-center justify-end">
35 <Link
36 className="text-gray-50 hover:text-yellow-200 font-bold"
37 href="/"
38 >
39 Home
40 </Link>
41
42 <div className="hxl:block">
43 {user ? (
44 <div className="flex items-center justify-end">
45 <span className="inline-block py-2 px-4 mr-2 leading-5 text-gray-50 hover:text-gray-100 bg-transparent font-medium rounded-md">
46 {user.username}
47 </span>
48 <button
49 className="inline-block py-2 px-4 text-sm leading-5 text-green-50 bg-green-500 hover:bg-green-600 font-medium focus:ring-2 focus:ring-green-500 focus:ring-opacity-50 rounded-md"
50 onClick={handleLogout}
51 >
52 Log Out
53 </button>
54 </div>
55 ) : (
56 <div className="flex items-center justify-end">
57 <Link
58 className="inline-block py-2 px-4 mr-2 leading-5 text-gray-50 hover:text-yellow-200 font-bold bg-transparent rounded-md"
59 href="/login"
60 >
61 Log In
62 </Link>
63 <Link
64 className="inline-block py-2 px-4 text-sm leading-5 text-green-50 bg-green-600 hover:bg-green-700 font-medium focus:ring-2 focus:ring-green-500 focus:ring-opacity-50 rounded-md"
65 href="/register"
66 >
67 Sign Up
68 </Link>
69 </div>
70 )}
71 </div>
72 </div>
73 </div>
74 </div>
75 </nav>
76 </header>
77 );
78}
79
80export default function Layout(props) {
81 const title = "Welcome to Next JS";
82
83 return (
84 <div>
85 <Head>
86 <title>{title}</title>
87 <meta charSet="utf-8" />
88 <meta name="viewport" content="initial-scale=1.0, width=device-width" />
89 </Head>
90 <Navigation />
91 <Cart />
92 <div className="container mx-auto px-4">{props.children}</div>
93 </div>
94 );
95}
Now if you refresh the page you should see the Cart component to the right of the dishes.
Your Layout header should also update with the username of the logged in user and show a logout button if you are logged-in.
Good job, let's finish the last step for ordering the food!
💵 In the next section, you will learn how to set up Stripe for checkout and create orders: https://strapi.io/blog/nextjs-react-hooks-strapi-checkout-6.
Ryan is an active member of the Strapi community and he's been contributing at a very early stage by writing awesome tutorial series to help fellow Strapier grow and learn.