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
You must start starving... I am sure you want to be able to order!
You need to store the orders in the database, so a new Content Type will be created in Strapi. Same process as usual:
+ Create new collection type
.order
as a name.Add New Field
and create the followings fields:address
with type Text
.city
with type Text
.state
with type Text
.amount
with type Number
(Int).token
with type Text
.dishes
with type JSON
.user
relation with user.To create new orders from the client, you are going to hit the create
endpoint of the order
API. To allow access, navigate to the Roles section (http://localhost:1337/admin/plugins/users-permissions), select the authenticated
role, tick the order/create
checkbox, and save.
In this section, you will need Stripe API keys.
To get them, create a Stripe account or log in to Stripe then navigate to https://dashboard.stripe.com/account/apikeys.
If you have already used Stripe, you probably know the credit card information does not go through your backend server. Instead, the credit card information is sent to the Stripe API (ideally using their SDK).
Then, your front end receives a token that can be used to charge credit cards.
The id
must be sent to your backend which will create the Stripe charge.
Not passing the credit card information through your server relieves you the responsibility to meet complicated data handling compliance, and is just far easier than worrying about securely storing sensitive data.
Install the stripe
package in the backend directory:
npm i stripe --save
To integrate the Stripe logic, you need to update the create
charge endpoint in our Strapi API.
You can learn more on how to customize Strapi controllers here
To do so, edit backend/src/api/order/controllers/order.js
and replace its content with the following code:
Make sure to insert your stripe secret key (sk_) at the top where it instructs.
Path: */backend/src/api/order/controllers/order.js*
1"use strict";
2const stripe = require("stripe")("sk_test_4eC39HqLyjWDarjtT1zdp7dc");
3
4/**
5 * order controller
6 */
7const { createCoreController } = require("@strapi/strapi").factories;
8module.exports = createCoreController("api::order.order", ({ strapi }) => ({
9 async create(ctx) {
10 const user = ctx.state.user;
11
12 if (!user) {
13 return ctx.unauthorized("You are not authorized!");
14 }
15
16 console.log(ctx.request.body.data);
17 console.log(ctx.state.user.id);
18 console.log("order controller");
19
20 const { address, amount, dishes, token, city, state } =
21 ctx.request.body.data;
22
23 try {
24 // Charge the customer
25 await stripe.charges.create({
26 amount: amount,
27 currency: "usd",
28 description: `Order ${new Date()} by ${ctx.state.user.id}`,
29 source: token,
30 });
31
32 // Create the order
33 const order = await strapi.service("api::order.order").create({
34 data: {
35 amount,
36 address,
37 dishes,
38 city,
39 state,
40 token,
41 user: ctx.state.user.id,
42 },
43 });
44 return order;
45 } catch (err) {
46 // return 500 error
47 console.log("err", err);
48 ctx.response.status = 500;
49 return {
50 error: { message: "There was a problem creating the charge" },
51 address,
52 amount,
53 dishes,
54 token,
55 city,
56 state,
57 };
58 }
59 },
60}));
Do not forget to restart the Strapi server.
To interact with the Stripe API, the react-stripe-js library will be used, which will give Elements components to style the credit card form and submit the information properly to Stripe.
Now install the stripe UI elements for the frontend. Open the frontend directory in the terminal and run the following command:
npm install @stripe/react-stripe-js @stripe/stripe-js
Now let's create our checkout form, in the pages directory create a new file called checkout.js
and add the following code.
1import { Elements } from "@stripe/react-stripe-js";
2import { loadStripe } from "@stripe/stripe-js";
3import { useInitialRender } from "@/utils/useInitialRender";
4import CheckoutForm from "@/components/CheckoutForm";
5import CheckoutCart from "@/components/CheckoutCart";
6const stripePromise = loadStripe("pk_test_TYooMQauvdEDq54NiTphI7jx");
7
8export default function Checkout() {
9 const initialRender = useInitialRender();
10 if (!initialRender) return null;
11
12 return (
13 <section className="container mx-auto py-24">
14 <div className="grid grid-cols-5 gap-4">
15 <div className="col-span-2">
16 <CheckoutCart />
17 </div>
18 <div className="col-span-3">
19 <Elements stripe={stripePromise}>
20 <CheckoutForm />
21 </Elements>
22 </div>
23 </div>
24 </section>
25 );
26}
You will notice that we are using some components from Stripe, you can learn more here.
As well as useInitialRender, before creating this function, the reason we are using this, is to check if the initial client side code rendered, otherwise we would get hydration miss-match error
.
Let's create the following file useInitialRender.js
inside our utils
folder and paste the following code:
1import { useState, useEffect } from "react";
2export const useInitialRender = () => {
3 const [initialRenderComplete, setInitialRenderComplete] = useState(false);
4
5 useEffect(() => {
6 if (!initialRenderComplete) setInitialRenderComplete(true);
7 }, [initialRenderComplete]);
8
9 return initialRenderComplete;
10};
Next, we are going to create the CheckoutForm
and CheckoutCart
components to capture the credit card info and pass it to Stripe using the react-stripe-elements package:
In the the components directory create a file named name CheckoutForm.jsx.
Once done, add the following code:
Path: frontend/components/CheckoutForm.jsx
1import React, { useState } from "react";
2import Cookie from "js-cookie";
3import { client } from "@/pages/_app.js";
4import { gql } from "@apollo/client";
5import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
6import { useAppContext } from "@/context/AppContext";
7import { useRouter } from "next/router";
8import { useInitialRender } from "@/utils/useInitialRender";
9
10const options = {
11 style: {
12 base: {
13 fontSize: "32px",
14 color: "#52a635",
15 "::placeholder": {
16 color: "#aab7c4",
17 },
18 },
19 invalid: {
20 color: "#9e2521",
21 },
22 },
23};
24
25const INITIAL_STATE = {
26 address: "",
27 city: "",
28 state: "",
29 error: null,
30};
31
32export default function CheckoutForm() {
33 const [data, setData] = useState(INITIAL_STATE);
34 const [loading, setLoading] = useState(false);
35 const { user, cart, resetCart, setShowCart } = useAppContext();
36
37 const initialRender = useInitialRender();
38
39 const stripe = useStripe();
40 const elements = useElements();
41 const router = useRouter();
42
43 if (!initialRender) return null;
44
45 function onChange(e) {
46 const updateItem = (data[e.target.name] = e.target.value);
47 setData({ ...data, updateItem });
48 }
49
50 async function submitOrder(e) {
51 e.preventDefault();
52 const cardElement = elements.getElement(CardElement);
53 const token = await stripe.createToken(cardElement);
54
55 if (data.address === "") {
56 setData({ ...data, error: { message: "Address is required" } });
57 return;
58 }
59
60 if (data.city === "") {
61 setData({ ...data, error: { message: "City is required" } });
62 return;
63 }
64
65 if (data.state === "") {
66 setData({ ...data, error: { message: "State is required" } });
67 return;
68 }
69
70 if (token.error) {
71 setData({ ...data, error: { message: token.error.message } });
72 return;
73 }
74
75 const jwt = Cookie.get("token");
76
77 try {
78 setLoading(true);
79
80 const { data: response } = await client.mutate({
81 mutation: gql`
82 mutation CreateOrder(
83 $amount: Int
84 $dishes: JSON
85 $address: String
86 $city: String
87 $state: String
88 $token: String
89 ) {
90 createOrder(
91 data: {
92 amount: $amount
93 dishes: $dishes
94 address: $address
95 city: $city
96 state: $state
97 token: $token
98 }
99 ) {
100 data {
101 id
102 attributes {
103 token
104 }
105 }
106 }
107 }
108 `,
109 variables: {
110 amount: cart.total,
111 dishes: cart.items,
112 address: data.address,
113 city: data.city,
114 state: data.state,
115 token: token.token.id,
116 },
117 context: {
118 headers: {
119 Authorization: `Bearer ${jwt}`,
120 },
121 },
122 });
123
124 if (response.createOrder.data) {
125 alert("Transaction Successful, continue your shopping");
126 setData(INITIAL_STATE);
127 resetCart();
128 setShowCart(true);
129 router.push("/");
130 }
131 } catch (error) {
132 setData({ ...data, error: { message: error.message } });
133 } finally {
134 setLoading(false);
135 }
136 }
137
138 return (
139 <form>
140 <div className="bg-white shadow-md rounded-lg p-8">
141 <h5 className="text-lg font-semibold">Your information:</h5>
142 <hr className="my-4" />
143 <div className="flex mb-6">
144 <div className="flex-1">
145 <label
146 className="block mb-2 test-gray-800 font-medium"
147 htmlFor="address"
148 >
149 Address
150 </label>
151 <input
152 id="address"
153 htmlFor="address"
154 className="appearance-none block w-full p-3 leading-5 text-gray-900 border border-gray-200 rounded-lg shadow-md placeholder-text-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
155 type="text"
156 name="address"
157 onChange={onChange}
158 placeholder="Enter your address"
159 />
160 </div>
161 </div>
162 <div className="flex mb-6">
163 <div className="flex-1 mr-6">
164 <label
165 htmlFor="city"
166 className="block mb-2 test-gray-800 font-medium"
167 >
168 City
169 </label>
170 <input
171 type="text"
172 name="city"
173 id="city"
174 onChange={onChange}
175 className="appearance-none block w-full p-3 leading-5 text-gray-900 border border-gray-200 rounded-lg shadow-md placeholder-text-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
176 />
177 </div>
178
179 <div className="w-1/4">
180 <label
181 htmlFor="state"
182 className="block mb-2 test-gray-800 font-medium"
183 >
184 State
185 </label>
186 <input
187 type="text"
188 name="state"
189 id="state"
190 onChange={onChange}
191 className="appearance-none block w-full p-3 leading-5 text-gray-900 border border-gray-200 rounded-lg shadow-md placeholder-text-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
192 />
193 </div>
194 </div>
195 {cart.items.length > 0 ? (
196 <div className="p-6">
197 <div>Credit or debit card</div>
198 <div className="my-4">
199 <CardElement options={options} />
200 </div>
201 <button
202 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"
203 onClick={(e) => (user ? submitOrder(e) : router.push("/login"))}
204 disabled={loading}
205 >
206 {loading ? "Submitting" : "Submit Order"}
207 </button>
208 </div>
209 ) : (
210 <div className="text-center">
211 <h1 className="text-2xl font-semibold">Your cart is empty</h1>
212 <p className="text-gray-500">
213 Add some items to your cart to continue
214 </p>
215 </div>
216 )}
217 <div>
218 {data.error && (
219 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
220 <strong className="font-bold">Error!</strong>{" "}
221 <span className="block sm:inline">{data.error.message}</span>
222 </div>
223 )}
224 </div>
225 </div>
226 </form>
227 );
228}
Here is what happens in our submitOrder function.
This is an asynchronous function named submitOrder that is triggered when clicking the order button and performs the following steps:
In summary, this code is a function for submitting an order that uses the Stripe API to create a payment token and GraphQL to create an order. It also handles errors and sets loading states accordingly.
Now let's create our CheckoutCart
component. Create a file inside our components folder called CheckoutCart.jsx
Path: frontend/components/CheckoutCart.jsx
1import { useAppContext } from "@/context/AppContext";
2import { centsToDollars } from "@/utils/centsToDollars";
3
4function CartItem({ data }) {
5 const { addItem, removeItem } = useAppContext();
6 const { quantity, attributes } = data;
7 return (
8 <div className="p-6 flex flex-wrap justify-between border-b border-blueGray-800">
9 <div className="w-2/4">
10 <div className="flex flex-col h-full">
11 <h6 className="font-bold text-white mb-1">{attributes.name}</h6>
12 <span className="block pb-4 mb-auto font-medium text-gray-400">
13 {quantity} x ${centsToDollars(attributes.priceInCents)}
14 </span>
15 </div>
16 </div>
17 <div className="w-1/4">
18 <div className="flex flex-col items-end h-full">
19 <div className="flex justify-between">
20 <button
21 className="mr-2 inline-block mb-auto font-medium text-sm text-gray-400 hover:text-gray-200"
22 onClick={() => removeItem(data)}
23 >
24 Remove
25 </button>
26 <button
27 className="inline-block mb-auto font-medium text-sm text-gray-400 hover:text-gray-200"
28 onClick={() => addItem(data)}
29 >
30 Add
31 </button>
32 </div>
33 <span className="block mt-2 text-sm font-bold text-white">
34 ${centsToDollars(attributes.priceInCents * quantity)}
35 </span>
36 </div>
37 </div>
38 </div>
39 );
40}
41
42export default function CheckoutCart() {
43 const { cart } = useAppContext();
44 const total = cart.total;
45 const displayTotal = Math.abs(total);
46
47 return (
48 <div className="rounded-2xl co bg-gray-800">
49 <div className="max-w-lg pt-6 pb-8 px-8 mx-auto bg-blueGray-900">
50 <div className="flex mb-10 items-center justify-between">
51 <h6 className="font-bold text-2xl text-white mb-0">Your Cart</h6>
52 </div>
53
54 <div>
55 {cart.items
56 ? cart.items.map((item, index) => {
57 if (item.quantity > 0) {
58 return <CartItem key={index} data={item} />;
59 }
60 })
61 : null}
62 </div>
63 <div className="p-6">
64 <div className="flex mb-6 content-center justify-between">
65 <span className="font-bold text-white">Order total</span>
66 <span className="text-sm font-bold text-white">
67 ${centsToDollars(displayTotal)}
68 </span>
69 </div>
70 </div>
71 </div>
72 </div>
73 );
74}
Now let's test our order form out, for a test credit card number you can just use 4242 4242 4242 4242
, that should work for you.
Now if you select a dish and click order, you should see:
You are now able to let users submit their orders.
Bon appétit!
🚀 In the next (and last) section, you will learn how to deploy your Strapi app on Heroku and your frontend app on NOW: https://strapi.io/blog/nextjs-react-hooks-strapi-deploy.
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.