[builderbook] Builder Book - Chapter 8. BuyButton component. Buy book logic. ReadChapter page. Checkout flow. MyBooks page. Mailchimp API. Deploy app PART 1

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, run yarn 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 and Stripe_Live_SecretKey ,
  • Stripe_Test_PublishableKey and Stripe_Live_PublishableKey ,
  • MAILCHIMP_API_KEY , MAILCHIMP_REGION , and MAILCHIMP_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 our ReadChapter 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.

  1. When we wrote our Header component as a stateless function ( components/Header.js ), we used Header.propTypes to specify types of props. But when we define a regular component with ES6 class, we specify types of props with static propTypes . For our BuyButton component, we should use the latter method, since we defined this component as a child ES6 class class 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';
 }
};
  1. Define book and user constants (block-scoped variables) as this.props.book and this.props.user , respectively. Also define the showModal constant as this.state.showModal . Using ES6 object destructuring:
const { book, user } = this.props;
const { showModal } = this.state;
  1. 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>
  1. 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. The StripeCheckout component requires two props: stripeKey and token . Other props are optional, and you are familiar with all of them except desktopShowModal . 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 stringify env . The proper way to stringify it is to use htmlescape() over JSON.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, use router.post() instead of router.get()
  • call Book.buy() instead of Book.list()
  • pass three parameters to the buy() static method: book id as id , Stripe token as stripeToken , and user We get the first two parameters from the request body req.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:

  1. use Mongoose’s findById() to find a book
  2. check if a user already bought a book by searching (and waiting) for a Purchase document with userId and bookId
  3. if a user did not buy a book, call and wait for a stripeCharge() method defined at server/stripe.js (discussed later in this section)
  4. send an email to a user (same way we sent a welcome email in Chapter 4)
  5. 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:

  1. Open server/models/Book.js and find the static method syncContent() that finds a book by id using the Mongoose method findById() :

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’);

  1. At this point in the code, we have both user and book objects. Here we use user._id from user and id from book to check if a Purchase document exists on our database. If a user bought a book already, we create a Purchase document in the Purchase collection of our database. It’s important to check if this Purchase 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 parameter isPurchased , 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');
}
  1. In this step, we pass three parameters to the stripeCharge() method. Parameters: book.price from book as amount , stripeToken.id from stripeToken (we passed this to the server from client via our buyBook API method) as token , user.email from user as buyerEmail (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 to getEmailTemplate() , we will pass bookTitle and bookUrl .
  • Second, the template name is purchase instead of welcome (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 the sendEmail() 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) and chapter.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 the render() method as const { user } = this.props; , since we pass user prop to the Header component
  • we defined a book constant inside the renderSidebar() function as const { book } = chapter; , we use book for book.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:

  1. After you buy a book, you have to manually refresh the page in order to see the full content.
  2. If you are logged out while viewing the ReadChapter page and then log in, you will be redirected to the MyBooks page. It would be better to redirect back to the ReadChapter page.
  3. If you are logged out and click the Buy book button (on the ReadChapter page), you will be redirected to the MyBooks page. It would be better to redirect back to the ReadChapter page you were reading when you clicked the Buy book button.
  4. If you are logged out and click the Buy book button, you have to sign in and then click the Buy book button again . It would better to see the Stripe modal right after logging in, so you only need to click the Buy Book button once .

Let’s discuss how we will solve each of the UX problems above.

  1. You may think of at least two ways to solve this problem. Ultimately, the goal is to re-run the getInitialProps() method inside the ReadChapter page ( pages/public/read-chapter.js ). Re-rendering a component with setState() will not trigger getInitialProps() to execute again. We need to re-run the getInitialProps() method to add htmlContent parameter to the chapterObj so full content can be displayed on the page. One complication is that, to re-run getInitialProps() , we need to pass headers.cookie to getInitialProps() again. We have to save headers.cookie in props.Here is the reason why the getInitialProps() method requires cookie 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 from req.user . In our Book.buy() static method ( server/models/Book.js ), we use user and user.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 the buyBook() API method has executed. Open components/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:

https://builderbook.org/books/builder-book/table-of-contents-highlight-for-section-hide-header-mobile-browser

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:
    1. To static PropTypes , add a new router prop:
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:
    1. Add a redirectUrl prop to the Header function:

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:

  1. Rewrite Login as a function instead of a constant
  2. Add the router prop to Login.propTypes
  3. Define redirectUrl as:

const redirectUrl = (router && router.query && router.query.redirectUrl) || ‘’;

  1. Pass redirectUrl to the href attribute of the <Button> element with:

/auth/google?redirectUrl=${redirectUrl}

  1. Wrap Login page component with withRouter HOC so Login receives router prop.After all changes, the updated pages/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!

  1. 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 the Buy book button. We’ve actually written most of the code for this feature. We will rely on the code we added to server/google.js . That means that we need to add a redirectUrl query to the URL when a logged-out user clicks the Buy book button on the ReadChapter page.Do you remember which function handles a click on Buy book from a logged-out user? It’s the onLoginClicked function from the BuyButton 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:

  1. 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;

  1. Add showStripeModal to static PropTypes :
    showStripeModal: PropTypes.bool.isRequired
  2. Finally, write code that makes showStripeModal detect the buy=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' to static 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.

1 симпатия

Could you please update all this book’s chapters!!!

1 симпатия

Chapter 9 is out. Could you please update it?

1 симпатия

Could you please update this course!!!