Chapter 8: BuyButton component. Buy book logic. ReadChapter page. Checkout flow. MyBooks page. Mailchimp API. Deploy app.
-
BuyButton component
-
Buy book logic
- API method buyBook()
- Express route ‘/buy-book’
- Static method Book.buy()
- stripeCharge() function
- Purchase model
-
ReadChapter page
- Excerpt and full content
- BuyButton
- Testing
-
Checkout flow
-
MyBooks page
- API method getMyBookList()
- Express route ‘/my-books’
- Static method Book.getPurchasedBooks()
-
Mailchimp API
-
Deploy app
- Prepare server
- Env keys
- Admin user
- Now
Before you start working on Chapter 8, get the 8-start
codebase. The 8-start folder is located at the root of the book
directory inside the builderbook repo.
- If you haven’t cloned the builderbook repo yet, clone it to your local machine with
git clone https://github.com/builderbook/builderbook.git
. - Inside the
8-start
folder, runyarn
to install all packages.
These are the packages that we install specifically for Chapter 8:
"compression"
"helmet"
"htmlescape"
"react-stripe-checkout"
"sitemap"
"stripe"
Check out the package.json for Chapter 8.
- Be sure to use these specific packages and ignore any warnings about upgrading. We regularly upgrade all packages and test them in the book. But before testing, we cannot guarantee that a new package version will work properly.
Remember to include your .env
at the root of your app. By the end of Chapter 8, you will add:
-
Stripe_Test_SecretKey
andStripe_Live_SecretKey
, -
Stripe_Test_PublishableKey
andStripe_Live_PublishableKey
, -
MAILCHIMP_API_KEY
,MAILCHIMP_REGION
, andMAILCHIMP_PURCHASED_LIST_ID
environmental variables to your.env
file.
In the previous chapter (Chapter 7), we made many UX improvements to our ReadChapter
page. In this chapter, we will add all code related to selling a book. Here is a high-level overview of what we want to accomplish:
- introduce a
BuyButton
component - define all necessary code to make the buy button functional: API method, Express route, and static methods for our Book model, Stripe API, and Purchase model
- add a
BuyButton
component to ourReadChapter
page and test the checkout flow - show books that a user bought on the
MyBooks
page (user dashboard) - add the email of a user who bought a book to a mailing list on Mailchimp
Like with any other new feature that needs to GET or POST data, the flow of data is usually as follows:
- there is a page or component that calls an API method
- the API method sends a request to a particular route, which executes a matching Express route
- the function inside the matching Express route calls a static method for a model, waits for data, and returns data to the client
By using this blueprint above, we’ve already implemented data exchange between client and server many times in this book. In Chapters 5 and 6, we added many pages/components, API methods, Express routes, and static methods related to our Admin user. In this section, we will write code related to our Customer user, who buys and reads a book.
Our first step, as per the blueprint, is to create a BuyButton
component that we eventually import and add to our ReadChapter
page. Inside this component, we will call the buyBook()
API method.
BuyButton component
In Chapter 2, we wrote our Header
component as a stateless functional component (link) and MenuDrop
component as a regular component (link).
The BuyButton
component will have both props and state. Thus, we should write it as a regular component. Check out components/MenuDrop.js
to remember how we write regular components. To define our new BuyButton
component with ES6 class:
class BuyButton extends React.Component { … }
We export a single value with default export:
export default BuyButton
Since the BuyButton
component is for our Customer user only, create a components/customer/BuyButton.js
file.
The carcass of the BuyButton
component with all necessary imports is:
components/customer/BuyButton.js
:
import React from 'react';
import PropTypes from 'prop-types';
import StripeCheckout from 'react-stripe-checkout';
import NProgress from 'nprogress';
import Button from '@material-ui/core/Button';
import { buyBook } from '../../lib/api/customer';
import notify from '../../lib/notifier';
const styleBuyButton = {
margin: '20px 20px 20px 0px',
font: '14px Muli',
};
class BuyButton extends React.Component {
// 1. propTypes and defaultProps
// 2. constructor (set initial state)
// 3. onToken function
// 4. onLoginClicked function
render() {
// 5. define variables with props and state
if (!book) {
return null;
}
if (!user) {
return (
// 6. Regular button with onClick={this.onLoginClicked} event handler
);
}
return (
// 7. StripeCheckout button with token and stripeKey parameters
);
}
}
export default BuyButton;
We discuss all code snippets in detail below.
- When we wrote our
Header
component as a stateless function (components/Header.js
), we usedHeader.propTypes
to specify types of props. But when we define a regular component with ES6 class, we specify types of props withstatic propTypes
. For ourBuyButton
component, we should use the latter method, since we defined this component as a child ES6 classclass BuyButton extends React.Component
. You get:
static defaultProps = {
book: null,
user: null,
showModal: false,
};
We will define and use these three props later in this section.
2. Similar to how there are two ways to specify types of props, there are two popular ways to set initial state. When you don’t use a prop to set an initial state object, you can simply write the following without using constructor:
state = {
open: false,
anchorEl: undefined,
};
We discussed constructor
usage cases in detail in Chapter 2 and Chapter 5.Open components/MenuDrop.js
to see how this was done for our MenuDrop
component.However, in the case of our BuyButton
component, we want to use a showModal
prop to set the initial state. Thus, we should use constuctor
:
constructor(props) {
super(props);
this.state = {
showModal: !!props.showModal,
};
}
Later in this chapter, we plan to pass a showModal
prop from the ReadChapter
page to our BuyButton
component. showModal: !!props.showModal
ensures that when showModal
is true in the ReadChapter
page, then showModal
is true in the BuyButton
component as well.As we discussed in Chapter 5, calling super(props)
makes props available inside constructor.
3. As discussed earlier in this book (Chapter 1, SSR) - for SSR, we call an API method inside getInitialProps()
; for CSR, we call an API method inside componentDidMount()
or inside an event-handling function.In the BuyButton
component, we want to call a buyBook()
API method after our user takes an action (clicks the buy button). So we defintely don’t want to do SSR and execute this buyBook()
API method on the server. To execute buyBook()
on the client, we would normally place buyBook()
inside componentDidMount()
. However, if placed inside componentDidMount()
, this API method will be called right after the component mounts on the client. This is not what we want - we want to call our API method on a click event . Thus, we place buyBook()
inside an onToken
function that gets executed after a user clicks the buy button.We will point onToken
to an async anonymous function and use the try/catch
construct along with async/await
(as we did many times before in this book). If you want to refresh your memory, check out Chapter 3. Let’s write an async anonymous function that calls the buyBook()
API method and uses Nprogress
and notify()
to communicate success to our user. You should get:
onToken = async (token) => {
NProgress.start();
const { book } = this.props;
this.setState({ showModal: false });
try {
await buyBook({ stripeToken: token, id: book._id });
notify('Success!');
NProgress.done();
} catch (err) {
NProgress.done();
notify(err);
}
};
When a user clicks the BuyButton
component, our code calls the onToken
function.We pass book
prop from the ReadChapter
page to the BuyButton
component. (We’ll discuss this more in the next section when we add BuyButton
to ReadChapter
.) Thus, constant book
is defined as this.props.book
. Using ES6 object destructuring:
const { book } = this.props
After a user clicks the buy button, we want to close the modal (Stripe modal with a form for card details):
this.setState({ showModal: false })
Then the code calls and waits for successful execution of our API method buyBook()
that takes two arguments:
await buyBook({ stripeToken: token, id: book._id });
At this point, the rest of the code should be self-explanatory.
4. Why do we need an onLoginClicked
function? If a user is logged in, then we simply call the onToken
function and buyBook()
API method. However, if a user is not logged in, we want to redirect the user to the /auth/google
route (Google OAuth).Similar to the book
prop, we pass the user
prop from our ReadChapter
page to the BuyButton
component:
const { user } = this.props
We check if a user
object exists, and it if does not exist or is empty, we redirect to Google OAuth:
if (!user) {
window.location.href = '/auth/google';
}
Put these two snippets together and you define onLoginClicked
:
onLoginClicked = () => {
const { user } = this.props;
if (!user) {
window.location.href = '/auth/google';
}
};
- Define
book
anduser
constants (block-scoped variables) asthis.props.book
andthis.props.user
, respectively. Also define theshowModal
constant asthis.state.showModal
. Using ES6 object destructuring:
const { book, user } = this.props;
const { showModal } = this.state;
- If a user is not logged in, we simply show a button from Material-UI. This button has an
onClick
event handler that points to{this.onLoginClicked}
:
<div>
<Button
variant="contained"
style={styleBuyButton}
color="primary"
onClick={this.onLoginClicked}
>
Buy for ${book.price}
</Button>
</div>
- If a user is logged in, we again show a button, but now we wrap it with
<StripeCheckout>...</StripeCheckout>
from the react-stripe-checkout package.Take a look at this example of usage. TheStripeCheckout
component requires two props:stripeKey
andtoken
. Other props are optional, and you are familiar with all of them exceptdesktopShowModal
. This prop controls whether the Stripe modal is open or closed. Read more about desktopShowModal.Wrap the Material-UI<Button>
with<StripeCheckout>
:
<StripeCheckout
stripeKey={StripePublishableKey}
token={this.onToken}
name={book.name}
amount={book.price * 100}
email={user.email}
desktopShowModal={showModal || null}
>
<Button variant="contained" style={styleBuyButton} color="primary">
Buy for ${book.price}
</Button>
</StripeCheckout>
As you can see from above, Stripe requires us to pass a publishable key to our component on the client . To do so, we need to define the global variable StripePublishableKey
and make this variable available to our components/customer/BuyButton.js
file. The problem is that env variables inside .env
are only available on server .
There are multiple ways to make environmental variables universally available. Universally, in this context, means on both client and server. Next.js provides at least two examples.
The first solution is to use the babel plugin babel-plugin-transform-define
. With this plugin, we can create a file that contains universally available variables. This solution requires us to create a configuration file for Babel ( .babelrc
), so we can extend Babel functionality with the babel-plugin-transform-define
plugin. As a team, we strive to keep as few configurations as possible. Otherwise, we may find ourselves in a so-called ‘configuration hell’.
The second solution does not require configuring Babel. Instead, it requires us to pass environmental variables as part of a command, like this:
StripePublishableKey=pk_test_12345 yarn dev
We simply prepended StripePublishableKey=pk_test_12345
to our yarn dev
command. After you run the above command, you are able to access the StripePublishableKey
environmental variable in your application as process.env.StripePublishableKey
. However, process.env.StripePublishableKey
is only accessible on the server, it’s not universally accessible.
We can make the StripePublishableKey
environmental variable available on the client by adding it to _document.js
. When a user loads a page for the first time, our Next.js app renders _document.js
on the server and sends it to the client together with any environmental variables.
Open your pages/_document.js
file. Under the imports section, add the following code snippet:
const { StripePublishableKey } = process.env;
// console.log(StripePublishableKey);
const env = { StripePublishableKey };
// console.log(env);
The first line simply defines our new variable StripePublishableKey
, which points to process.env.StripePublishableKey
.
The fourth line, creates an env.StripePublishableKey
parameter inside the env
object. This parameter’s value is StripePublishableKey
(defined on the first line).
We used ES6 object destructuring for both of the above lines.
We also used object shorthand on the fourth line - we wrote const env = { StripePublishableKey }
instead of const env = { StripePublishableKey: StripePublishableKey }
.
Start your app with StripePublishableKey=pk_test_12345 yarn dev
. Uncomment the console.log()
statements and reload the page so your app executes _document.js
. Your terminal will print:
pk_test_12345
{ StripePublishableKey: 'pk_test_12345' }
This terminal output means that we successfully accessed our StripePublishableKey
value using process.env
and successfully created an env
object that has the env.StripePublishableKey
name-value pair.
The next step is to add an env
object to the MyDocument
component. Find this location in page/_document.js
:
<Main />
<NextScript />
Modify like this:
<Main />
{/* eslint-disable-next-line react/no-danger */}
<script dangerouslySetInnerHTML={{ __html: `__ENV__ = ${htmlescape(env)}` }} />
<NextScript />
The first of these newly added lines simply disables the Eslint warning for dangerouslySetInnerHTML
.
The second line adds a script
tag to the Document
component.
Two explanatory notes:
- You may have noticed two underscore symbols on each side of
__ENV__
. We’ve added underscores to avoid name collision and to indicate that tge variable is used on the browser. This is a practical convention, read more about it here. - The JavaScript object
env
can’t be rendered as HTML since it’s not a string. We need to stringifyenv
. The proper way to stringify it is to usehtmlescape()
overJSON.stringify()
since JSON is not exactly a subset of JavaScript. Some characters inside the string that are accepted by JSON are not accepted by JavaScript.
Go to your browser, open Dev Tools > Elements
, and find our newly added script
tag:
On the client (browser console), we have an __ENV__
object with the expected value for StripePublishableKey
.
In Next.js, the initial load is server-side rendered and subsequent loads are client-side rendered. Thus we need to write conditional code to account for this conditional behavior.
Create a lib/env.js
file that contains only a single line of code:
export default (typeof window !== ‘undefined’ ? window.ENV : process.env);
If a window
objects exists ( typeof window !== 'undefined'
), then this file exports window.__ENV__
, which is {"StripePublishableKey":"pk_test_12345"}
. That’s because in our browser window, we have a script
tag with content __ENV__ = {"StripePublishableKey":"pk_test_12345"}
.
Else , when a window
object does not exist (server environment), the file exports the server-side environment, which is process.env
.
That’s pretty much it! We’ve covered both cases for page rendering: server-side and client-side.
Open your components/customer/BuyButton.js
file. Import env
from lib/env.js
:
import env from ‘…/…/lib/env’;
Then access StripePublishableKey
like this:
const { StripePublishableKey } = env;
From now on, when you run your app locally, remember to start your app with:
StripePublishableKey=pk_test_xxxxxx yarn dev
Replace pk_test_xxxxxx
with your actual value for the test publishable key from Stripe.
Plug the code from steps 1-7 into our BuyButton
component carcass that we outlined in the beginning of this section:
import React from 'react';
import PropTypes from 'prop-types';
import StripeCheckout from 'react-stripe-checkout';
import NProgress from 'nprogress';
import Button from '@material-ui/core/Button';
import { buyBook } from '../../lib/api/customer';
import notify from '../../lib/notifier';
import env from '../../lib/env';
const { StripePublishableKey } = env;
const styleBuyButton = {
margin: '20px 20px 20px 0px',
font: '14px Muli',
};
class BuyButton extends React.Component {
static propTypes = {
book: PropTypes.shape({
_id: PropTypes.string.isRequired,
}),
user: PropTypes.shape({
_id: PropTypes.string.isRequired,
}),
showModal: PropTypes.bool,
};
static defaultProps = {
book: null,
user: null,
showModal: false,
};
constructor(props) {
super(props);
this.state = {
showModal: !!props.showModal,
};
}
onToken = async (token) => {
NProgress.start();
const { book } = this.props;
this.setState({ showModal: false });
try {
await buyBook({ stripeToken: token, id: book._id });
notify('Success!');
NProgress.done();
} catch (err) {
NProgress.done();
notify(err);
}
};
onLoginClicked = () => {
const { user } = this.props;
if (!user) {
window.location.href = '/auth/google';
}
};
render() {
const { book, user } = this.props;
const { showModal } = this.state;
if (!book) {
return null;
}
if (!user) {
return (
<div>
<Button
variant="contained"
style={styleBuyButton}
color="primary"
onClick={this.onLoginClicked}
>
Buy for ${book.price}
</Button>
</div>
);
}
return (
<StripeCheckout
stripeKey={StripePublishableKey}
token={this.onToken}
name={book.name}
amount={book.price * 100}
email={user.email}
desktopShowModal={showModal || null}
>
<Button variant="contained" style={styleBuyButton} color="primary">
Buy for ${book.price}
</Button>
</StripeCheckout>
);
}
}
export default BuyButton;
Above, we wrote the basic version of our BuyButton
component. As we progress in this chapter, we will improve the UX of our checkout flow and make necessary changes to this component. We will make these improvements in the section called Checkout flow
.
We will test out our BuyButton
component at the end of the next section, after adding it to our ReadChapter
page.
As you’ve probably noticed from the code above - after a user submits card details, we call the onToken
function that calls a buyBook()
API method. In the next section, we discuss this API method and its corresponding Express route and static method for the Book model. We also discuss Stripe API and create a new Purchase model.
Buy book logic
In this section, we will discuss the buyBook()
API method, Express route /buy-book
, and static method buy()
for our Book model. As you learned in Chapters 5 and 6:
- a page or component calls an API method
- the API method sends a request to an Express route
- that Express route calls and waits for a static method
- the static method fetches or updates data using Mongoose methods (aka Mongoose Queries)
API method buyBook()
Let’s decide which type of method to use for a request sent by our buyBook
API method. Since we send book._id
(as id
) and token
(as stripeToken
) to the server , the method should be POST. Because the method is POST, you don’t need to specify it explicitly, as POST is a default method (check out lib/api/sendRequest.js
, which we wrote in Chapter 5).
Next, let’s decide the route to use for the API endpoint; for example, let it be /buy-book
.
If you don’t remember how we wrote API methods, check out lib/api/admin.js
and Chapter 5, Section Internal APIs. For example, to display a list of books on our Admin dashboard:
lib/api/admin.js
:
import sendRequest from './sendRequest';
const BASE_PATH = '/api/v1/admin';
export const getBookList = () =>
sendRequest(`${BASE_PATH}/books`, {
method: 'GET',
});
Create a lib/api/customer.js
file with the buyBook
API method:
lib/api/customer.js
:
import sendRequest from './sendRequest';
const BASE_PATH = '/api/v1/customer';
export const buyBook = ({ id, stripeToken }) =>
sendRequest(`${BASE_PATH}/buy-book`, {
body: JSON.stringify({ id, stripeToken }),
});
Notice that we pass stringified id
and stripeToken
values to the request body. We will get these values from the request body when we write our Express route.
Express route ‘/buy-book’
First things first, we need to make sure that Customer-related API endpoints are indeed defined on the server. Open your server/api/index.js
file and find the line server.use('/api/v1/customer', customerApi);
:
server/api/index.js
:
const publicApi = require('./public');
const customerApi = require('./customer');
const adminApi = require('./admin');
function api(server) {
server.use('/api/v1/public', publicApi);
server.use('/api/v1/customer', customerApi);
server.use('/api/v1/admin', adminApi);
}
module.exports = api;
To remember how you wrote these Express routes, open up server/api/admin.js
. Here is one Express route that calls and waits for the static method Book.list()
:
server/api/admin.js
:
const express = require('express');
const Book = require('../models/Book');
const User = require('../models/User');
const { getRepos } = require('../github');
const logger = require('../logs');
const router = express.Router();
router.use((req, res, next) => {
if (!req.user || !req.user.isAdmin) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
next();
});
router.get('/books', async (req, res) => {
try {
const books = await Book.list();
res.json(books);
} catch (err) {
res.json({ error: err.message || err.toString() });
}
});
module.exports = router;
To write an Express route for our buyBook
API method, keep in mind a few differences:
- inside the Express middleware that checks for user permissions, we don’t need to check for
!req.user.isAdmin
- the route is
/buy-book
- the method is
POST
; thus, userouter.post()
instead ofrouter.get()
- call
Book.buy()
instead ofBook.list()
- pass three parameters to the
buy()
static method: book id asid
, Stripe token asstripeToken
, anduser
We get the first two parameters from the request bodyreq.body
using ES6 object destructuring:
const { id, stripeToken } = req.body;
The third parameter is from req.user
that we discussed in detail in Chapter 3.
Go to your server/api/customer.js
file.
Take into account these specifics above and put together the Express middleware and Express route:
server/api/customer.js
:
const express = require('express');
const Book = require('../models/Book');
const logger = require('../logs');
const router = express.Router();
router.use((req, res, next) => {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
next();
});
router.post('/buy-book', async (req, res) => {
const { id, stripeToken } = req.body;
try {
await Book.buy({ id, stripeToken, user: req.user });
res.json({ done: 1 });
} catch (err) {
logger.error(err);
res.json({ error: err.message || err.toString() });
}
});
module.exports = router;
In a typical Express route with the GET
method, we would send a response that contains some data from our database (for example, a book
or chapter
object). However, in a typical Express route with the POST
method, we can simply respond with the object done: 1
or save: 1
or success: 1
.
Static method Book.buy()
With the API method and Express route out of way, let’s discuss the static method buy()
in our Book model. This static method will do multiple things:
- use Mongoose’s
findById()
to find a book - check if a user already bought a book by searching (and waiting) for a
Purchase
document withuserId
andbookId
- if a user did not buy a book, call and wait for a
stripeCharge()
method defined atserver/stripe.js
(discussed later in this section) - send an email to a user (same way we sent a welcome email in Chapter 4)
- finally, create a new
Purchase
document with all necessary parameters
High-level structure of our buy
static method that takes the three arguments discussed earlier:
server/models/Book.js
:
static async buy({ id, user, stripeToken }) {
if (!user) {
throw new Error('User required');
}
// 1. find book by id
if (!book) {
throw new Error('Book not found');
}
// 2. check if user bought book already
// 3. call stripeCharge() method
// 4. send transactional email confirming purchase
// 5. create new Purchase document
}
The only two snippets of code that we did not mention are those that throw errors in case user
and book
objects do not exist.
Let’s discuss each step in detail:
- Open
server/models/Book.js
and find the static methodsyncContent()
that finds a book by id using the Mongoose methodfindById()
:
const book = await this.findById(id, ‘userId githubRepo githubLastCommitSha’);
In our case, instead of returning userId githubRepo githubLastCommitSha
parameters, we want to return name slug price
. Thus, the code for step 1 is simply:
const book = await this.findById(id, ‘name slug price’);
- At this point in the code, we have both
user
andbook
objects. Here we useuser._id
fromuser
andid
frombook
to check if aPurchase
document exists on our database. If a user bought a book already, we create aPurchase
document in thePurchase
collection of our database. It’s important to check if thisPurchase
document exists, so a user does not buy a book twice. This is an unlikely edge case, but let’s take care of it.Define the boolean parameterisPurchased
, which is true if the count of found documents is greater than zero:
const isPurchased = (await Purchase.find({ userId: user._id, bookId: id }).countDocuments()) > 0;
If isPurchased
is true, then we throw an error. Our code for step 2 is:
const isPurchased = (await Purchase.find({ userId: user._id, bookId: id }).countDocuments()) > 0;
if (isPurchased) {
throw new Error('Already bought this book');
}
- In this step, we pass three parameters to the
stripeCharge()
method. Parameters:book.price
frombook
asamount
,stripeToken.id
fromstripeToken
(we passed this to the server from client via ourbuyBook
API method) astoken
,user.email
fromuser
asbuyerEmail
(we define this method in the next subsection). Code for step 3:
const chargeObj = await stripeCharge({
amount: book.price * 100,
token: stripeToken.id,
buyerEmail: user.email,
});
For bookkeeping, we will save chargeObj
as a parameter in the Purchase
document in step 5.
4. Remember how we send the transactional welcome email to new users - open server/models/User.js
and find the static method signInOrSignUp()
inside it:
const template = await getEmailTemplate('welcome', {
userName: displayName,
});
try {
await sendEmail({
from: `Kelly from Builder Book <${process.env.EMAIL_SUPPORT_FROM_ADDRESS}>`,
to: [email],
subject: template.subject,
body: template.message,
});
} catch (err) {
logger.error('Email sending error:', err);
}
The code for sending an email after a user bought a book will be very similar. Here are small differences to keep in mind:
- First, in addition to passing the
userName
parameter togetEmailTemplate()
, we will passbookTitle
andbookUrl
. - Second, the template name is
purchase
instead ofwelcome
(we don’t want to send the welcome email again). - Third, since we are not inside the User model but Book model, the value of
to
inside thesendEmail()
method is[user.email]
instead of[email]
.With these change in mind, you will get the following code for step 4:
const template = await getEmailTemplate('purchase', {
userName: user.displayName,
bookTitle: book.name,
bookUrl: `${ROOT_URL}/books/${book.slug}/introduction`,
});
try {
await sendEmail({
from: `Kelly from builderbook.org <${process.env.EMAIL_SUPPORT_FROM_ADDRESS}>`,
to: [user.email],
subject: template.subject,
body: template.message,
});
} catch (error) {
logger.error('Email sending error:', error);
}
Since we used ROOT_URL
, we have to define it within a scope of server/models/Book.js
. Add following line of code right after section with imports:
const ROOT_URL = 'http://localhost:8000';
We don’t have an EmailTemplate with the name purchase
. Open server/models/EmailTemplate.js
and add a purchase
template right after the welcome
template:
{
name: 'purchase',
subject: 'You purchased book at builderbook.org',
message: `<%= userName %>,
<p>
Thank you for purchasing our book! You will get confirmation email from Stripe shortly.
</p>
<p>
Start reading your book: <a href="{{bookUrl}}" target="_blank">{{bookTitle}}</a>
</p>
<p>
If you have any questions while reading the book,
please fill out an issue on
<a href="https://github.com/builderbook/builderbook/issues" target="blank">Github</a>.
</p>
Kelly & Timur, Team Builder Book
`,
},
Done. The next time you start your app, a new EmailTemplate document with "name": "purchase"
will be created on MongoDB.
5. This one is straightforward. To create a new Purchase
document, we use the Mongoose method create()
on our Purchase model. Besides adding a createdAt
timestamp, we pass the following parameters to the document: user._id
, book._id
, book.price
, and chargeObject
as userId
, bookId
, amount
, and stripeCharge
, respectively:
return Purchase.create({
userId: user._id,
bookId: book._id,
amount: book.price * 100,
stripeCharge: chargeObj,
createdAt: new Date(),
});
Plug in the code from steps 1 to 5 into our high-level structure of the buy
static method. Add this method within BookClass
of our Book model at server/models/Book.js
:
static async buy({ id, user, stripeToken }) {
if (!user) {
throw new Error('User required');
}
const book = await this.findById(id, 'name slug price');
if (!book) {
throw new Error('Book not found');
}
const isPurchased = (await Purchase.find({ userId: user._id, bookId: id }).countDocuments()) > 0;
if (isPurchased) {
throw new Error('Already bought this book');
}
const chargeObj = await stripeCharge({
amount: book.price * 100,
token: stripeToken.id,
buyerEmail: user.email,
});
const template = await getEmailTemplate('purchase', {
userName: user.displayName,
bookTitle: book.name,
bookUrl: `${ROOT_URL}/books/${book.slug}/introduction`,
});
try {
await sendEmail({
from: `Kelly from builderbook.org <${process.env.EMAIL_SUPPORT_FROM_ADDRESS}>`,
to: [user.email],
subject: template.subject,
body: template.message,
});
} catch (error) {
logger.error('Email sending error:', error);
}
return Purchase.create({
userId: user._id,
bookId: book._id,
amount: book.price * 100,
stripeCharge: chargeObj,
createdAt: new Date(),
});
}
This is pretty much the final version of our buy()
static method. We will make only one upgrade - later in this chapter, we will call the subscribe()
method to add a user’s email to a Mailchimp list.
Remember to add all missing imports to server/models/Book.js
:
const Purchase = require('./Purchase');
const getEmailTemplate = require('./EmailTemplate');
const { stripeCharge } = require('../stripe');
const sendEmail = require('../aws');
In the next two subsections, we will discuss stripeCharge()
and Purchase model. We imported and used them inside Book.buy()
, but we have not defined them.
stripeCharge() method
To create a charge with Stripe, we use the stripe package and follow this official example:
var stripe = require('stripe')('sk_test_...');
var customer = await stripe.customers.create(
{ email: 'customer@example.com' }
);
There is no strong reason for us to create customer
as shown in the official example, since buying a digital book is a one-time transaction. Instead of creating customer
, we want to create charge
, so the code will be stripe.charges.create()
instead of stripe.customers.create
.
Let’s define API_KEY
for development and production environments:
const dev = process.env.NODE_ENV !== 'production';
const API_KEY = dev ? process.env.Stripe_Test_SecretKey : process.env.Stripe_Live_SecretKey;
Next, define the constant client
as well as stripe
with the passed API_KEY
:
const client = stripe(API_KEY);
We will specify only five parameters out of two dozen possible parameters for stripeCharge()
(to see all parameters, check Charge, Stripe API). In addition to the three parameters that we passed to stripeCharge()
inside our Book.buy()
static method, we will add two more:
currency: 'usd',
description: 'Payment for the book at builderbook.org',
For charge creation, you get:
return client.charges.create({
amount,
source: token,
receipt_email: buyerEmail,
currency: 'usd',
description: 'Payment for the book at builderbook.org',
});
Create a server/stripe.js
file. Add definitions of API_KEY
, client
and above snippet that creates charge:
server/stripe.js
:
const stripe = require('stripe');
function stripeCharge({
amount, token, buyerEmail,
}) {
const dev = process.env.NODE_ENV !== 'production';
const API_KEY = dev ? process.env.Stripe_Test_SecretKey : process.env.Stripe_Live_SecretKey;
const client = stripe(API_KEY);
return client.charges.create({
amount,
source: token,
receipt_email: buyerEmail,
currency: 'usd',
description: 'Payment for the book at builderbook.org',
});
}
exports.stripeCharge = stripeCharge;
Remember to get Stripe_Test_SecretKey
and Stripe_Live_SecretKey
values from your Stripe account and add them to the .env
file.
You will need to add a total of 4 Stripe keys to .env
file, 2 publishable and 2 secret. After you create Stripe account, find keys at https://dashboard.stripe.com/account/apikeys.
Purchase model
Our Purchase model is straighforward to write. There are no static methods to define. We want to ensure the uniqueness of our index with:
mongoSchema.index({ bookId: 1, userId: 1 }, { unique: true });
We discussed MongoDB indices in detail in Chapter 5, section Chapter model.
Check out User, Book, Chapter, and EmailTemplate models to remember how to create a model and specify Schema:
const Purchase = mongoose.model(‘Purchase’, mongoSchema);
We create a Purchase document inside the Book.buy()
static method with:
return Purchase.create({
userId: user._id,
bookId: book._id,
amount: book.price * 100,
stripeCharge: chargeObj,
createdAt: new Date(),
});
Based on the information above, define Schema:
const { Schema } = mongoose;
const mongoSchema = new Schema({
userId: {
type: Schema.Types.ObjectId,
required: true,
},
bookId: {
type: Schema.Types.ObjectId,
required: true,
},
amount: {
type: Number,
required: true,
},
stripeCharge: {
id: String,
amount: Number,
created: Number,
livemode: Boolean,
paid: Boolean,
status: String,
},
createdAt: {
type: Date,
required: true,
},
});
Create a server/models/Purchase.js
file and add the following code:
server/models/Purchase.js
:
const mongoose = require('mongoose');
const { Schema } = mongoose;
const mongoSchema = new Schema({
userId: {
type: Schema.Types.ObjectId,
required: true,
},
bookId: {
type: Schema.Types.ObjectId,
required: true,
},
amount: {
type: Number,
required: true,
},
stripeCharge: {
id: String,
amount: Number,
created: Number,
livemode: Boolean,
paid: Boolean,
status: String,
},
createdAt: {
type: Date,
required: true,
},
});
mongoSchema.index({ bookId: 1, userId: 1 }, { unique: true });
const Purchase = mongoose.model('Purchase', mongoSchema);
module.exports = Purchase;
In the next section ReadChapter page
, we will add our BuyButton
component to the ReadChapter
page and make a few UX improvements to our checkout flow.
ReadChapter page
In this section, we will make necessary changes to our ReadChapter
page so that a logged-out user can see preview content and then buy the full content of a book. Two main goals for this section are:
- conditionally show
chapter.htmlExcerpt
(preview content) andchapter.htmlContent
(full content) - add the
BuyButton
component and pass props to it
Excerpt and full content
In this subsection, we modify the ReadChapter
page so it shows either chapter.htmlExcerpt
or chapter.htmlContent
depending on whether a user bought a book or not.
Open pages/public/read-chapter.js
and find instances of htmlContent
. In Chapter 5, we simply defined htmlContent
as:
let htmlContent = '';
if (chapter) {
htmlContent = chapter.htmlContent;
}
With this definition, the content you write inside a .md
file of a Github repo will be displayed on the webpage, unpaywalled. This is fine for writing a free book or documentation. However, we would like our app to support both free and paid content. Thus, we introduce the isFree
parameter for chapters.
In Chapter 6, you forked our demo-book repo. Navigate to the chapter-1.md
file and click the Edit icon. Here is a link to the original file, and here is snapshot of the page:
If you add isFree: true
to the metadata section of the chapter-1.md
file, then the Chapter document created from this file will get a "isFree": true
parameter after an Admin user clicks the Sync with Github
button. We introduced the code responsible for saving parameters from metadata to a Chapter document in Chapter 6.
To make sure that this code works, navigate to the introduction.md
file of your forked demo-book
repository and click the Edit icon. You will see isFree: true
inside the metadata section:
Now go to Atlas, go to the test.chapters
collection, and find the chapter document created from the introduction.md
file. You will indeed see the proper isFree
parameter:
Alright, now we have a useful boolean parameter, chapter.isFree
, that we can use to control whether we show chapter.htmlExcerpt
or chapter.htmlContent
. When chapter.isFree
is true, the page should display chapter.htmlContent
. When false, chapter.htmlExcerpt
.
The next step is to introduce another boolean parameter: chapter.isPurchased
. This will allow users who purchased a book to see its content. When the chapter.isPurchased
parameter is true, the page should display chapter.htmlContent
, even when chapter.isFree
is false. To set isPurchased
on the chapter
object, we have to modify the getBySlug()
static method inside our Chapter model.
Open server/models/Chapter.js
and find the getBySlug()
static method:
static async getBySlug({ bookSlug, chapterSlug }) {
const book = await Book.getBySlug({ slug: bookSlug });
if (!book) {
throw new Error('Book not found');
}
const chapter = await this.findOne({ bookId: book._id, slug: chapterSlug });
if (!chapter) {
throw new Error('Chapter not found');
}
const chapterObj = chapter.toObject();
chapterObj.book = book;
return chapterObj;
}
In Chapter 5, when we wrote the getBySlug
static method, we passed userId
and isAdmin
parameters to this method from corresponding Express route. But we haven’t used them yet (though we mentioned that we would in Chapter 8).
Add these two parameters in the getBySlug()
function like this:
static async getBySlug({
bookSlug, chapterSlug, userId, isAdmin,
})
Now, let’s modify the getBySlug
static method by adding code that checks whether the chapter isPurchased
. We’ll add the code before we return chapterObj with return chapterObj
. Follow the example from our Book.buy()
static method located at server/models/Book.js
. There, we defined isPurchased
like this:
const isPurchased = (await Purchase.find({ userId: user._id, bookId: id }).countDocuments()) > 0;
Here, we will make some slight changes. We will check:
- that the purchase document exists (
Purchase.findOne({ userId, bookId: book._id })
) - whether a user is Admin (
isAdmin
), - whether the chapter is free (
chapter.isFree
)
To check for all three, you may get something like this:
if (userId) {
const purchase = await Purchase.findOne({ userId, bookId: book._id });
chapterObj.isPurchased = !!purchase || isAdmin;
}
const isFreeOrPurchased = chapter.isFree || chapterObj.isPurchased;
if (!isFreeOrPurchased) {
delete chapterObj.htmlContent;
}
First note - chapterObj.isPurchased
is true if a purchase exists or if a user is Admin.
Second note - we remove the full content of htmlContent
from the chapterObj
object when chapterObj.isFreeOrPurchased
is false. If a chapter is not free and not purchased, then chapterObj
will not contain htmlContent
. This is a very important step, since our app renders ReadChapter
page initially on the server . Writing conditional logic that hides full content on the client is not sufficient that’s why we added above server-side logic.
Add the above code snippet to the getBySlug
method in our Chapter model. Paste the snippet above this line of code:
return chapterObj;
Remember to import the Purchase model to server/models/Chapter.js
with:
const Purchase = require(’./Purchase’);
Great, we have both boolean parameters we wanted: chapter.isFree
and chapter.isPurchased
!
Writing a conditional construct inside the ReadChapter
page will be easy. Open pages/public/read-chapter.js
and find two places where we defined htmlContent
. First, inside constructor
:
let htmlContent = '';
if (chapter) {
htmlContent = chapter.htmlContent;
}
Second, inside componentWillReceiveProps()
:
const { htmlContent } = chapter;
Replace both of these definitions of htmlContent
with:
let htmlContent = '';
if (chapter && (chapter.isPurchased || chapter.isFree)) {
htmlContent = chapter.htmlContent;
} else {
htmlContent = chapter.htmlExcerpt;
}
The condition (chapter.isPurchased || chapter.isFree)
ensures that we show the full content of chapter.htmlContent
only in the following cases:
- user bought the book
- user is an Admin
- Admin user specifies
isFree: true
in the metadata of a.md
file
BuyButton
Now that we’ve set up conditions for showing free or paid content to particular users, let’s add a buy button to appear on paid books.
Go to the ReadChapter
page. Open the pages/public/read-chapter.js
file and import the BuyButton
component:
import BuyButton from ‘…/…/components/customer/BuyButton’;
In the same file, find the renderMainContent()
function. Inside this function, find the element:
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
Under this element, which is htmlContent
, add the buy button ( BuyButton
component):
{!chapter.isPurchased && !chapter.isFree ? (
<BuyButton user={user} book={chapter.book} />
) : null}
This conditional construct ensures that a page does not display the buy button on a chapter when chapter.isPurchased
or chapter.isFree
is true.
We pass user
, book
, and showModal
props to our BuyButton
component. We have already defined the props user
and book
inside the ReadChapter
page component. Go ahead and find them:
- we defined a
user
constant inside therender()
method asconst { user } = this.props;
, since we passuser
prop to theHeader
component - we defined a
book
constant inside therenderSidebar()
function asconst { book } = chapter;
, we usebook
forbook.slug
So we know how to define user
and book
inside the local scope of the renderMainContent()
function:
renderMainContent() {
const { user } = this.props;
const {
chapter, htmlContent, showTOC, isMobile,
} = this.state;
const { book } = chapter;
let padding = '20px 20%';
if (!isMobile && showTOC) {
padding = '20px 10%';
} else if (isMobile) {
padding = '0px 10px';
}
return (
<div style={{ padding }} id="chapter-content">
<h2 style={{ fontWeight: '400', lineHeight: '1.5em' }}>
{chapter.order > 1 ? `Chapter ${chapter.order - 1}: ` : null}
{chapter.title}
</h2>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
{!chapter.isPurchased && !chapter.isFree ? (
<BuyButton user={user} book={book} />
) : null}
</div>
);
}
Time to test.
Testing
We should make UX improvements to our checkout flow, but before we do, let’s test the code we wrote so far in this chapter.
Start your app with yarn dev
, log in with a non-Admin user, and navigate to
http://localhost:8000/books/demo-book/example
As expected, you will see chapter.htmlExcerpt
and the BuyButton
component!
Log out, and log in with an Admin
user (if you have only one Google account, set "isAdmin": true
on your user document at MongoDB).
Navigate to http://localhost:8000/books/demo-book/example
.
As expected, the page displays the full content of chapter.htmlContent
, and the BuyButton
component has disappeared:
If you try to test the checkout flow, it will fail with the error Not found
. The Book.buy()
method fails because the EmailTemplate document with the name purchase
can’t be found in our database.
To make our app create this missing EmailTemplate document, you need to go to Atlas and delete the current emailtemplates
collection. Then stop your app and start it again with yarn dev
. Check the newly created emailtemplates
collection - it will contain two documents with the names welcome
and purchase
. Now we are ready for testing the checkout flow.
Notice that at this point your database has no purchases
collection.
Log out and log in with a non-Admin user again (or remove "isAdmin": true
from your user document at MongoDB).
Navigate to http://localhost:8000/books/demo-book/example
.
Click the Buy book
button and you will see the Stripe modal:
In test mode, Stripe suggests using the card number 4242 4242 4242 4242 with any CVC (why not 4242) and any date in the future.
Click the Pay $49
button on the Stripe modal, and you will see a Success!
in-app message. Then after reloading the page, you will see the full content:
In your database, the app created a new collection called purchases
. The very first Purchase document looks like:
Success, the checkout flow indeed works.
Reloading a page manually to see the full content is not a good UX. In the next section, we will reload the page automatically and make a few more UX improvements.
Checkout UX
In the previous section, we implemented a checkout flow and showed that it works as expected. However, a few experiences inside our checkout flow are not smooth. For example:
- After you buy a book, you have to manually refresh the page in order to see the full content.
- If you are logged out while viewing the
ReadChapter
page and then log in, you will be redirected to theMyBooks
page. It would be better to redirect back to theReadChapter
page. - If you are logged out and click the
Buy book
button (on theReadChapter
page), you will be redirected to theMyBooks
page. It would be better to redirect back to theReadChapter
page you were reading when you clicked theBuy book
button. - If you are logged out and click the
Buy book
button, you have to sign in and then click theBuy book
button again . It would better to see the Stripe modal right after logging in, so you only need to click theBuy Book
button once .
Let’s discuss how we will solve each of the UX problems above.
- You may think of at least two ways to solve this problem. Ultimately, the goal is to re-run the
getInitialProps()
method inside theReadChapter
page (pages/public/read-chapter.js
). Re-rendering a component withsetState()
will not triggergetInitialProps()
to execute again. We need to re-run thegetInitialProps()
method to addhtmlContent
parameter to thechapterObj
so full content can be displayed on the page. One complication is that, to re-rungetInitialProps()
, we need to passheaders.cookie
togetInitialProps()
again. We have to saveheaders.cookie
in props.Here is the reason why thegetInitialProps()
method requirescookie
as an argument. As discussed earlier in Chapter 2, section Session, the server needs a cookie to identify a session. Then the server uses session to get a user id, and finally the server uses user id to get a user fromreq.user
. In ourBook.buy()
static method (server/models/Book.js
), we useuser
anduser.isAdmin
to see if a user bought a book or if a user is an Admin.A simpler solution is to trigger a window reload after thebuyBook()
API method has executed. Opencomponents/customer/BuyButton.js
and find this snippet:
try {
await buyBook({ stripeToken: token, id: book._id });
notify('Success!');
NProgress.done();
} catch (err) {
NProgress.done();
notify(err);
}
As you know from Chapter 3, section Async/await, the code pauses at the line with await buyBook()
. After this line, add a new line:
window.location.reload(true);
This method location.reload(true) reloads a page from the server.
2. Imagine you bought a book but you logged out of the app. Say you go back to Chapter 2 of the book. Let’s modify the code so that when you click the Log in
link (in the Header
) and log in with Google, you’ll be automatically redirected back to Chapter 2 instead of the MyBooks
page.Take a look at the code responsible for redirecting a user after Google login at server/google.js
. Find this anonymous arrow function:
(req, res) => {
if (req.user && req.user.isAdmin) {
res.redirect('/admin');
} else {
res.redirect('/my-books');
}
},
We need to modify the above conditional logic. We still want to redirect an Admin user to /admin
. But a Customer user should be redirected to the ReadChapter
page if the user clicked the Log in
link or Buy book
button while on the ReadChapter
page.We suggest creating a new parameter, redirectUrl
, that contains the URL of the ReadChapter
page.We can append this redirectUrl
value to the Login
page URL ( /login
) and Google OAuth URL ( /auth/google
). Then, after a user logs in, we can read redirectUrl
from the appended URL and redirect a user back to the ReadChapter
page. For example, for the URL:
The redirectUrl
would be:
%2Fbooks%2Fbuilder-book%2Ftable-of-contents-highlight-for-section-hide-header-mobile-browser
URL encoded %2F
stands for slash /
.First, let’s work on the redirect when a user clicks the Log in
link inside the Header
component. Open server/google.js
and find the /auth/google
Express route:
server.get(
'/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email'],
prompt: 'select_account',
}),
);
We want to re-write this route in order to save a redirectUrl
string value to the req.session
object before calling passport.authenticate('google')
. We want both req.query
and req.query.redirectUrl
to exist and make sure that the latter starts with /
. We do so with the JavaScript method startsWith(’/’) that outputs true or false:
if (req.query && req.query.redirectUrl && req.query.redirectUrl.startsWith('/')) {
req.session.finalUrl = req.query.redirectUrl;
} else {
req.session.finalUrl = null;
}
If all three conditions are true, we save redirectUrl
to our session object as req.session.finalUrl
.
The updated Express route will look like:
server.get('/auth/google', (req, res, next) => {
if (req.query && req.query.redirectUrl && req.query.redirectUrl.startsWith('/')) {
req.session.finalUrl = req.query.redirectUrl;
} else {
req.session.finalUrl = null;
}
passport.authenticate('google', {
scope: ['profile', 'email'],
prompt: 'select_account',
})(req, res, next);
});
Third argument next
in the above middleware functions ensures that Express passes control to downstream middleware function since these the above middleware functions do not end request-response cycle (for example, they don’t send any response to user).At this point, we saved a redirectUrl
value to req.session
. The next step is to use it for an actual redirect. In the same file, find this function inside the /oauth2callback
Express route:
(req, res) => {
if (req.user && req.user.isAdmin) {
res.redirect('/admin');
} else {
res.redirect('/my-books');
}
},
Before the else
condition, we should add an else if
condition to redirect a user to req.session.finalUrl
with res.redirect(req.session.finalUrl)
:
else if (req.session.finalUrl) {
res.redirect(req.session.finalUrl);
}
Updated function:
(req, res) => {
if (req.user && req.user.isAdmin) {
res.redirect('/admin');
} else if (req.session.finalUrl) {
res.redirect(req.session.finalUrl);
} else {
res.redirect('/my-books');
}
},
This conditional logic redirects an Admin user to /admin
and redirects a Customer user to req.session.redirectUrl
if req.query.redirectUrl
exists. However, based on the last statement, our redirect feature does not work yet. It will only work when req.query
(i.e. the part of the URL after ?redirectUrl=
) has a value (see how we defined req.session.finalUrl
earlier). Our next step is to engineer req.query.redirectUrl
.Let’s keep in mind what we want to achieve. We want a user who is on the ReadChapter
page and who clicks the Log in
link in the Header
component to be redirected back to the same ReadChapter
page after logging in. That means that:
- The URL of the
Login
page must contain the?redirectUrl=
query with a value. - Once the URL of the
Login
page has this query, the app will be able to read the query value and pass it to/auth/google?redirectUrl=
. - After that, the updated Express route
server.get('/auth/google)
will take care of the rest.Start your app and make sure you are logged out. Go to Chapter 1 (titled “Example”) of the demo-book repo you forked earlier. The URL of this page is:
http://localhost:8000/books/demo-book/example
We want to make sure that when you click Log in
in the Header
, the URL of the Login page is:
http://localhost:8000/public/login?redirectUrl=%2Fbooks%2Fdemo-book%2Fexample
Instead of:
http://localhost:8000/public/login
The easiest way to achieve this is to pass the redirectUrl
prop from the ReadChapter
page to the Header
component. Let’s make neccesary changes to ReadChapter
and Header
.
- In
pages/public/read-chapter.js
, make the following 3 changes:- To
static PropTypes
, add a newrouter
prop:
- To
router: PropTypes.shape({
asPath: PropTypes.string.isRequired,
}).isRequired,
2. Inside the main `render()` method, define a `prop` variable as `this.props.router` :
const { user, router } = this.props;
3. Pass a router.asPath
value to the redirectUrl
prop on the Header
component like this:
Header user={user} hideHeader={hideHeader} redirectUrl={router.asPath}
Read about router
object, its type, and its asPath
parameter in the Next.js docs for router object.
In our case, router.asPath
is /books/demo-book/example
.
If in doubt, print this value to the browser console ( Developer tools > Console
) by adding console.log(router.asPath);
after const { user, router } = this.props;
inside render()
of the pages/public/read-chapter.js
file.Add import:
import { withRouter } from ‘next/router’;
Then add router
prop to props:
static propTypes = {
chapter: PropTypes.shape({
_id: PropTypes.string.isRequired,
}),
user: PropTypes.shape({
_id: PropTypes.string.isRequired,
}),
router: PropTypes.shape({
asPath: PropTypes.string.isRequired,
}).isRequired,
};
Remember to wrap ReadChapter
page component with withRouter
to pass router
prop to ReadChapter
component:
export default withAuth(withLayout(withRouter(ReadChapter), { noHeader: true }), {
loginRequired: false,
});
- In
components/Header.js
, make the following 3 changes:- Add a
redirectUrl
prop to theHeader
function:
- Add a
function Header({ user, hideHeader, redirectUrl })
2. Update the `href` value in the `<Link>` element for `Log in` . It becomes:
<Link
prefetch
href={{ pathname: '/public/login', asPath: '/login', query: { redirectUrl } }}>
<a style={{ margin: '0px 20px 0px auto' }}>Log in</a>
</Link>
We specified that query is a value after ?redirectUrl=
with this property shorthand: query: { redirectUrl }
.
3. Add the redirectUrl
prop to propTypes
and defaultProps
. Add redirectUrl: PropTypes.string
and redirectUrl: ''
to propTypes
and defaultProps
blocks respectively.We are ready to test.
Start your app. While logged out, go to http://localhost:8000/books/demo-book/example
.
Click the Log in
link in the Header
. You will be redirected to the Login page
that has URL:
http://localhost:8000/public/login?redirectUrl=%2Fbooks%2Fdemo-book%2Fexample
It almost works!
query
parameter is added to the URL, however asPath
parameter did not work: we still see /public/login/...
instead of /login/...
.As we found out after writing this book, asPath on
Link
component does not work and work around is to pass as
to Link
:
<Link
prefetch
href={{ pathname: '/public/login', query: { redirectUrl } }}>
as={{ pathname: '/login', query: { redirectUrl } }}>
<a style={{ margin: '0px 20px 0px auto' }}>Log in</a>
</Link>
Pass as
object to Link
component as it shown in the above snippet.
Go to http://localhost:8000/books/demo-book/example
and click on Log in
link inside the Header
.
Now browser will show you proper URL:
http://localhost:8000/login?redirectUrl=%2Fbooks%2Fdemo-book%2Fexample
Try clicking the red Log in with Google
button and log in as a Customer (non-Admin) user. Login worked but you are not be redirected back to Chapter 1. That’s because we did not pass the redirectUrl
query to the /auth/google
route yet.
Open pages/public/login.js
and make these 4 changes:
- Rewrite
Login
as a function instead of a constant - Add the
router
prop toLogin.propTypes
- Define
redirectUrl
as:
const redirectUrl = (router && router.query && router.query.redirectUrl) || ‘’;
- Pass
redirectUrl
to thehref
attribute of the<Button>
element with:
/auth/google?redirectUrl=${redirectUrl}
- Wrap
Login
page component withwithRouter
HOC soLogin
receivesrouter
prop.After all changes, the updatedpages/public/login.js
file is:
pages/public/login.js
:
import Head from 'next/head';
import { withRouter } from 'next/router';
import PropTypes from 'prop-types';
import Button from '@material-ui/core/Button';
import withAuth from '../../lib/withAuth';
import withLayout from '../../lib/withLayout';
import { styleLoginButton } from '../../components/SharedStyles';
function Login({ router }) {
const redirectUrl = (router && router.query && router.query.redirectUrl) || '';
return (
<div style={{ textAlign: 'center', margin: '0 20px' }}>
<Head>
<title>
Log in to Builder Book
</title>
<meta name="description" content="Login page for builderbook.org" />
</Head>
<br />
<p style={{ margin: '45px auto', fontSize: '44px', fontWeight: '400' }}>
Log in
</p>
<p>
You’ll be logged in for 14 days unless you log out manually.
</p>
<br />
<Button
variant="contained"
style={styleLoginButton}
href={`/auth/google?redirectUrl=${redirectUrl}`}
>
<img
src="https://storage.googleapis.com/builderbook/G.svg"
alt="Log in with Google"
style={{ marginRight: '10px' }}
/>
Log in with Google
</Button>
</div>
);
}
Login.propTypes = {
router: PropTypes.shape({
query: PropTypes.shape({
redirectUrl: PropTypes.string,
}),
}).isRequired,
};
export default withAuth(withLayout(withRouter(Login), { logoutRequired: true });
Now we can test this entire new feature.
Start your app. While logged out, go to:
http://localhost:8000/books/demo-book/example
Click Log in
in the Header
. You will be redirected to the Login page
with this URL:
http://localhost:8000/public/login?redirectUrl=%2Fbooks%2Fdemo-book%2Fexample
Hover your cursor over the Log in with Google
button, and you will see the following URL in the bottom-left corner of your browser window:
http://localhost:8000/auth/google?redirectUrl=/books/demo-book/example
Log in as a Customer (non-Admin) user, and you will be redirected back to:
http://localhost:8000/books/demo-book/example
Hooray!
- The next step is to work on a slightly different redirect - taking a user back to the
ReadChapter
page when a logged-out user clicks theBuy book
button. We’ve actually written most of the code for this feature. We will rely on the code we added toserver/google.js
. That means that we need to add aredirectUrl
query to the URL when a logged-out user clicks theBuy book
button on theReadChapter
page.Do you remember which function handles a click onBuy book
from a logged-out user? It’s theonLoginClicked
function from theBuyButton
component (components/customer/BuyButton.js
):
onLoginClicked = () => {
const { user } = this.props;
if (!user) {
window.location.href = '/auth/google';
}
};
We need to make sure that /auth/google
gets the redirectUrl
query. In this local scope, define redirectUrl
as location.pathname. location.pathname
gives the same value as Next.js’s router.asPath
(see above). It’s the part of the URL without the domain root:
const redirectUrl = window.location.pathname;
Append the value of redirectUrl
to /auth/google?redirectUrl=
. Set the page’s URL to this appended URL by using location.href:
const redirectUrl = window.location.pathname;
window.location.href = `/auth/google?redirectUrl=${redirectUrl}`;
Updated onLoginClicked
function:
onLoginClicked = () => {
const { user } = this.props;
if (!user) {
const redirectUrl = window.location.pathname;
window.location.href = `/auth/google?redirectUrl=${redirectUrl}`;
}
};
Time to test it out.Make sure you are logged out and navigate to: http://localhost:8000/books/demo-book/example
Click on the blue Buy book
button.
You will see the Google OAuth prompt. After logging in (as a Customer), you will be automatically redirected back to:
http://localhost:8000/books/demo-book/example
Good job.The only part that still sucks, UX-wise, is that the Stripe modal did not appear automatically. You clicked the Buy book
button because you intend to buy a book. But our app requires you to click Buy book
one more time. That’s annoying.
4. To show the Stripe modal to a user who clicked on the Buy book
button while logged out, we need to somehow change the value of the showModal
prop inside the BuyButton
component. We need to detect that a logged-user clicked the Buy book
button on the ReadChapter
page. Then we need to pass a showModal
prop with the value true
from the ReadChapter
page to the BuyButton
component.
Earlier in this chapter, we set the initial state of the Buy book
component with:
this.state = {
showModal: !!props.showModal,
};
This code ensures that showModal
from the ReadChapter
page has the same value as showModal
in the scope of the BuyButton
component.The question is when should the ReadChapter
page pass this showModal
prop to the BuyButton
component?An easy solution is to modify our onLoginClicked
function so that /auth/google?redirectUrl=${redirectUrl}
is further appended with a unique query, say ?buy=1
. Inside our onLoginClicked
function, append ?buy=1
to /auth/google?redirectUrl=${redirectUrl}
so that it becomes:
/auth/google?redirectUrl=${redirectUrl}?buy=1
Next, make changes to the ReadChapter
page component so it passes showModal
to the BuyButton
component:
- Update the
<BuyButton>
component by passing this extra prop:
BuyButton user={user} book={book} showModal={showStripeModal}
Inside renderMainContent
, define showStripeModal
as this.props.showStripeModal
with ES6 destructuring:
const { user, showStripeModal } = this.props;
- Add
showStripeModal
tostatic PropTypes
:
showStripeModal: PropTypes.bool.isRequired
- Finally, write code that makes
showStripeModal
detect thebuy=1
query in the URL:
const showStripeModal = req ? !!req.query.buy : window.location.search.includes(‘buy=1’);
This code above says that, when req
exists, showStripeModal
is true when the query ?buy=
exists ( req.query.buy
exists thus !!req.query.buy
is true). When req
does not exist, then showStripeModal
is true when window.location.search.includes('buy=1')
exists. location.search returns the query part of the URL - in our case, everything after the first ?
symbol. For example, for Chapter 1 of our demo book, window.location.search
:
?redirectUrl=/books/demo-book/example?buy=1
The JavaScript method includes(‘buy=1’) checks if buy=1
is included in window.location.search
.We defined showStripeModal
with conditional logic because sometimes the page is server-side rendered ( req.query.buy
is available on the server) and sometimes client-side rendered ( window.location.search
is available on client).Add the above definition of showStripeModal
inside the getInitialProps()
method like this:
static async getInitialProps({ req, query }) {
const { bookSlug, chapterSlug } = query;
const headers = {};
if (req && req.headers && req.headers.cookie) {
headers.cookie = req.headers.cookie;
}
const chapter = await getChapterDetail({ bookSlug, chapterSlug }, { headers });
const showStripeModal = req ? !!req.query.buy : window.location.search.includes('buy=1');
return { chapter, showStripeModal };
}
Our code is ready for testing.While logged out, navigate to: http://localhost:8000/books/demo-book/example
Click on the blue Buy book
button. Log in as a Customer (non-Admin) user.
You will be automatically redirected back to Chapter 1:
http://localhost:8000/books/demo-book/example?buy=1
This time you will the Stripe modal and there is no need for you to click Buy book
again:
We are done with UX improvements to our checkout flow. In the next section, we will work on the MyBooks
page. Our main goal is to display a hyperlinked title for the book purchased by a Customer user.
MyBooks page
The MyBooks
page is a Customer page, and it contains a list of books purchased by a user. Think of it as the Customer’s dashboard.
Before we start writing code for any new page, the first step is to decide how to render that page - server-side or client-side.
For example, the Admin
page ( pages/admin/index.js
) is client-side rendered, and the getBookList()
API method is called inside the componentDidMount()
lifecycle hook. If you load the /admin
page, you will notice that the page loads without data (without the list of books). Data appears with a small but noticable delay. If Admin UX is our priority, we would display some kind of loading UI while the Admin user waits for data to appear. For example, we could’ve displayed Nprogress, a spinner, or simply text loading...
.
In comparison, the ReadChapter
page is server-side rendered (on initial load), and the getChapterDetail()
API method is called inside the getInitialProps()
method instead of componentDidMount()
. The UX advantage of a server-side rendered page is that it does not require loading UI. The page loads with data, never without data.
Admin UX is not our first priority - Customer UX is. Thus, we will implement the MyBook
page so that it is server-side rendered on initial load. We will place the API method getMyBookList()
inside the getInitialProps()
method.
You created multiple page at this point. The most recent server-side rendered page was the ReadChapter
page. Take a look at the getInitialProps()
method at pages/public/read-chapter.js
:
static async getInitialProps({ req, query }) {
const { bookSlug, chapterSlug } = query;
const headers = {};
if (req && req.headers && req.headers.cookie) {
headers.cookie = req.headers.cookie;
}
const chapter = await getChapterDetail({ bookSlug, chapterSlug }, { headers });
const showStripeModal = req ? !!req.query.buy : window.location.search.includes('buy=1');
return { chapter, showStripeModal };
}
One similarity between the MyBooks
and ReadChapter
pages is that we will pass a cookie
as headers.cookie
to the server. If we don’t do this, then req.user
won’t be available on the server. (You remember from Chapter 3 that cookie has encoded session id, session has user id, and with this user id, the server can find a user and create req.user
that is used in many Express routes to check user permissions or to update user parameters.)
The difference between these pages is that ReadChapter
shows chapter.htmlExcerpt
to a logged-out user, but MyBooks
should not be available at all to a logged-out user. Good UX would be to redirect the logged-out user, who stumbled upon the /my-books
route, to the Login
page:
if (req && !req.user) {
res.redirect('/login');
return { purchasedBooks: [] };
}
If a user is logged in, then we define and return an array of purchased books purchasedBooks
like this:
const { purchasedBooks } = await getMyBookList({ headers });
return { purchasedBooks };
Summarize the above description of the getInitialProps()
method:
static async getInitialProps({ req, res }) {
if (req && !req.user) {
res.redirect('/login');
return { purchasedBooks: [] };
}
const headers = {};
if (req && req.headers && req.headers.cookie) {
headers.cookie = req.headers.cookie;
}
const { purchasedBooks } = await getMyBookList({ headers });
return { purchasedBooks };
}
Remember how you rendered a list of sections using the map()
JavaScript method. Open pages/public/read-chapter.js
and find the renderSections()
function:
renderSections() {
const { sections } = this.state.chapter;
const { activeSection } = this.state;
if (!sections || !sections.length === 0) {
return null;
}
return (
<ul>
{sections.map(s => (
<li key={s.escapedText} style={{ paddingTop: '10px' }}>
<a
style={{
color: activeSection && activeSection.hash === s.escapedText ? '#1565C0' : '#222',
}}
href={`#${s.escapedText}`}
onClick={this.closeTocWhenMobile}
>
{s.text}
</a>
</li>
))}
</ul>
);
}
Let’s use this example above for purchasedBooks
:
{purchasedBooks && purchasedBooks.length > 0 ? (
<div>
<h3>Your books</h3>
<ul>
{purchasedBooks.map(book => (
<li key={book._id}>
<Link
as={`/books/${book.slug}/introduction`}
href={`/public/read-chapter?bookSlug=${book.slug}&chapterSlug=introduction`}
>
<a>{book.name}</a>
</Link>
</li>
))}
</ul>
</div>
) : (
<div>
<h3>Your books</h3>
<p>You have not purchased any book.</p>
</div>
)}
That’s it, the page is pretty much done. Go to your pages/customer/my-books
file and put together the getInitialProps()
and render()
methods. Inside render()
, remember to define purchasedBooks
with:
const { purchasedBooks } = this.props;
Add missing imports, propTypes, defaultProps, and <Head>
with <title>
. Wrap the page component with withLayout
and withAuth
HOCs. Export with export default withAuth(withLayout(MyBooks));
:
pages/customer/my-books.js
:
import React from 'react';
import PropTypes from 'prop-types';
import Link from 'next/link';
import Head from 'next/head';
import { getMyBookList } from '../../lib/api/customer';
import withLayout from '../../lib/withLayout';
import withAuth from '../../lib/withAuth';
class MyBooks extends React.Component {
static propTypes = {
purchasedBooks: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
})),
};
static defaultProps = {
purchasedBooks: [],
};
static async getInitialProps({ req, res }) {
if (req && !req.user) {
res.redirect('/login');
return { purchasedBooks: [] };
}
const headers = {};
if (req && req.headers && req.headers.cookie) {
headers.cookie = req.headers.cookie;
}
const { purchasedBooks } = await getMyBookList({ headers });
return { purchasedBooks };
}
render() {
const { purchasedBooks } = this.props;
return (
<div>
<Head>
<title>My Books</title>
</Head>
<div style={{ padding: '10px 45px' }}>
{purchasedBooks && purchasedBooks.length > 0 ? (
<div>
<h3>Your books</h3>
<ul>
{purchasedBooks.map(book => (
<li key={book._id}>
<Link
as={`/books/${book.slug}/introduction`}
href={`/public/read-chapter?bookSlug=${book.slug}&chapterSlug=introduction`}
>
<a>{book.name}</a>
</Link>
</li>
))}
</ul>
</div>
) : (
<div>
<h3>Your books</h3>
<p>You have not purchased any book.</p>
</div>
)}
</div>
</div>
);
}
}
export default withAuth(withLayout(MyBooks));
This page won’t get any data yet, since we did not define the getMyBookList
API method and did not write an Express route nor static method for getMyBookList
in our Book model.
API method getMyBookList
Since we pass headers
to getMyBookList
, we should take a look at the getChapterDetails
API method that accepts headers
as well. Open lib/api/public.js
:
export const getChapterDetail = ({ bookSlug, chapterSlug }, options = {}) =>
sendRequest(
`${BASE_PATH}/get-chapter-detail?bookSlug=${bookSlug}&chapterSlug=${chapterSlug}`,
Object.assign(
{
method: 'GET',
},
options,
),
);
Re-write the above API method. Keep in mind that we pass headers
as options.headers
, and the non-base part of API endpoint is /my-books
:
lib/api/customer.js
:
export const getMyBookList = (options = {}) =>
sendRequest(
`${BASE_PATH}/my-books`,
Object.assign(
{
method: 'GET',
},
options,
),
);
Express route ‘/my-books’
Our next step is to write an Express route with the method GET
and route /my-books
. At this point, you know that a function inside /my-books
Express route should call and wait for static method and then return purchasedBooks
. Let’s make the static method Book.getPurchasedBooks()
return purchasedBooks
:
const { purchasedBooks } = await Book.getPurchasedBooks({ purchasedBookIds });
The above static method requires ids of books that are purchased by a user. Where do we get these ids? Since we did pass a cookie to the server, then req.user
exists. We can look into req.user.purchaseBookIds
for ids of books purchased by a user. This means that we need to modify our Book.buy()
static method so that the user document of a user who buys a book gets purchaseBookIds
array as a parameter.
Open server/models/User.js
and make 2 small changes to the file:
- add
purchasedBookIds: [String]
to mongoSchema, - add
'purchasedBookIds'
tostatic publicFields()
Open server/models/Book.js
. Add a line of code that finds and updates a user who bought a book. Use the Mongoose method findByIdAndUpdate()
:
User.findByIdAndUpdate(user.id, { $addToSet: { purchasedBookIds: book.id } }).exec();
Add the above line of code right before the line with const template = await getEmailTemplate()
inside the static method buy()
.
Remember to import User
to server/models/Book.js
. Eslint should tell you if an import is missing.
Back to the Express route.
Let’s define purchasedBooksIds
with ES6 object destructuring:
const { purchasedBookIds = [] } = req.user;
We are ready to put our Express route together. As always, we suggest using try/catch
with async/await
:
server/api/customer.js
:
router.get('/my-books', async (req, res) => {
try {
const { purchasedBookIds = [] } = req.user;
const { purchasedBooks } = await Book.getPurchasedBooks({ purchasedBookIds });
res.json({ purchasedBooks });
} catch (err) {
res.json({ error: err.message || err.toString() });
}
});
The only missing part in our client-server data exchange is the static method getPurchasedBooks()
.
Static method Book.getPurchasedBooks()
In the previous section, we wrote code that saves a purchased book id to a user document.
Start your app. Make sure you use a Customer user who did not buy demo-book
. If necessary, go to your test.purchases
collection at MongoDB Atlas and delete the Purchase document for your Customer user who bought demo-book
. Now buy a book and go to your test.users
collection at MongoDB Atlas, test
database. Open the user document and you will see a new array:
"purchasedBookIds": [
"5a90d1c1b0466e2eee5f8508"
]
The static method getPurchasedBooks()
finds all books in the books collection using the purchasedBookIds
array, and the method returns purchasedBooks
to the Express route /my-books
. We will use the find()
and sort()
Mongoose methods that you know well by this point:
server/models/Book.js
:
static async getPurchasedBooks({ purchasedBookIds }) {
const purchasedBooks = await this.find({ _id: { $in: purchasedBookIds } }).sort({
createdAt: -1,
});
return { purchasedBooks };
}
You may not be familiar with the { $in: [array] }
operator. $in allows us to find all documents with a field
value matching one of the values inside an array:
{ field: { $in: [, , … ] } }
Start your app, log in, and navigate to http://localhost:8000/my-books
:
Ta-da, you now see a list of purchased books.
In the next section, we discuss how to add user emails to a Mailchimp list. In the section after that, you will learn how to prepare your app for production and deploy it.