Headless Commerce with Craft CMS & Gatsby

(an experiment)

πŸ‘‹πŸ»

I'm Brian Hanson

@brianjhanson

Headless

What should we build?

MVP

  • Product list page
  • Product single page
  • Checkout flow
    • Add / update cart
    • Payment

On the docket

Initial Project Setup

Getting Data

Add / Update Cart

User sessions

Deployment &Β Rebuilding

Initial Project Setup

Craft

Gatsby

client/
β”œβ”€β”€ LICENSE
β”œβ”€β”€ README.md
β”œβ”€β”€ gatsby-browser.js
β”œβ”€β”€ gatsby-config.js
β”œβ”€β”€ gatsby-node.js
β”œβ”€β”€ gatsby-ssr.js
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
└── src
    β”œβ”€β”€ components
    β”œβ”€β”€ images
    └── pages
server/
β”œβ”€β”€ composer.json
β”œβ”€β”€ composer.lock
β”œβ”€β”€ config
β”œβ”€β”€ craft
β”œβ”€β”€ craft.bat
β”œβ”€β”€ modules
β”œβ”€β”€ plugins
β”œβ”€β”€ storage
β”œβ”€β”€ templates
β”œβ”€β”€ vendor
└── web

Host configuration

Craft

Gatsby

api.headless.test

headless.test

server/config/general.php
<?php
/**
 * General Configuration
 *
 * All of your system's general configuration settings go in here. You can see a
 * list of the available settings in vendor/craftcms/cms/src/config/GeneralConfig.php.
 *
 * @see \craft\config\GeneralConfig
 */

return [
  // Global settings
  '*'          => [
    // Your default settings ...
    
    // Not covered by this POC
    'enableCsrfProtection' => false,

    // Add cookie domain to make life easier
    'defaultCookieDomain' => '.headless.test',

    // Turn on the new headless mode 😎
    'headlessMode' => true
  ],
  
  // Other environment config
];

Getting Data

No GraphQL for Commerce

Element API &
Gatsby Source API Server to the rescue

The Strategy

  • Craft is supplying data via element-api
  • Gatsby is consuming that data via GraphQL thanks to the gatsby-source-apiserver plugin
  • Running a build Gatsby will make a request through its own node server and render the page using that data
server/config/element-api.php
<?php
use craft\commerce\elements\Product;

return [
  'endpoints' => [
    'products.json'                 => static function () {
      return [
        'elementType' => Product::class,
        'criteria'    => [],
        'transformer' => static function ( Product $product ) {
          return [
            'title'          => $product->title,
            'slug'           => $product->slug,
            'id'             => $product->id,
            'defaultVariant' => $product->defaultVariant,
            'defaultPrice'   => $product->defaultPrice,
            'variants'       => $product->getVariants(),
            'dateUpdated'    => $product->dateUpdated
          ];
        },
      ];
    },
  ]
];
client/gatsby-config.js
module.exports = {
  // Additonal config ...
  plugins: [
    // other plugins ...
    {
      resolve: "gatsby-source-apiserver",
      options: {
        // Type prefix of entities from server
        typePrefix: "commerce__",

        // The url, this should be the endpoint you are attempting to pull data from
        url: `${process.env.GATSBY_API_URL}/products.json`,
        method: "get",

        // Name of the data to be downloaded.  Will show in graphQL or be saved to a file
        // using this name. i.e. posts.json
        name: "products",

        // Nested level of entities in response object, example: `data.products`
        entityLevel: `data`,
      },
    },
  ],
}
client/src/pages/index.js
import React from "react";
import { Link, graphql } from "gatsby";

import Layout from "../components/layout";
import SEO from "../components/seo";

const IndexPage = ({ data }) => {
  const products = data.products.nodes;
  return (
    <Layout>
      <SEO title="Home" />
      <div>
        {products.map(product => {
          return (
            <h3 key={product.slug}>
              <Link to={`/${product.slug}`}>{product.title}</Link>
            </h3>
          );
        })}
      </div>
    </Layout>
  );
};

// Here renaming allCommerceProducts to products
// the allCommerceProducts name is equal to
// all<TypePrefix><Name> from our config
export const pageQuery = graphql`...`;
import React from "react";
import { Link, graphql } from "gatsby";

import Layout from "../components/layout";
import SEO from "../components/seo";

const IndexPage = ({ data }) => {...};

// Here renaming allCommerceProducts to products
// the allCommerceProducts name is equal to
// all<TypePrefix><Name> from our config
export const pageQuery = graphql`
  query {
    products: allCommerceProducts(filter: { id: { ne: "dummy" } }) {
      nodes {
        title
        slug
        id: alternative_id
      }
    }
  }
`;
const path = require(`path`)

// https://github.com/gatsbyjs/gatsby/issues/6011
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"

exports.createPages = ({ graphql, actions }) => {
  const { createPage } = actions

  return graphql(
    `
      {
        allCommerceProducts(filter: { id: { ne: "dummy" } }) {
          edges {
            node {
              slug
            }
          }
        }
      }
    `
  )
    .then(result => {...})
    .catch(err => {...})
}
client/gatsby-node.js
const path = require(`path`)

exports.createPages = ({ graphql, actions }) => {
  const { createPage } = actions

  return graphql(...)
    .then(result => {
      if (result.errors) {
        throw result.errors
      }

      // Create blog posts pages.
      const products = result.data.allCommerceProducts.edges

      products.forEach(product => {
        createPage({
          path: product.node.slug,
          component: path.resolve("./src/templates/product.js"),
          context: {
            slug: product.node.slug,
          },
        })
      })

      return null
    })
    .catch(err => {...})
}
client/gatsby-node.js

Add / Update Cart

// templates/product.js
import ...

const ProductTemplate = ({ data, location }) => {
  const product = data.product;
  const price = parseFloat(product.defaultPrice);

  return (
    <Layout location={location}>
      <h1>{product.title}</h1>
      <p>{`$${price.toFixed(2)}`}</p>
    </Layout>
  )
}

export default ProductTemplate

export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
    }
  }
`
// templates/product.js
import ...

const ProductTemplate = ({ data, location }) => {
  const product = data.product;
  const price = parseFloat(product.defaultPrice);

  return (
    <Layout location={location}>
      <h1>{product.title}</h1>
      <p>{`$${price.toFixed(2)}`}</p>
      <button type="button" onClick={updateCart}>
        Add To Cart
      </button>
    </Layout>
  )
}

export default ProductTemplate

export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
    }
  }
`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
    }
  }
`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
      defaultVariant {
        id: alternative_id
      }
    }
  }
`;

πŸ‘Ž

CORS

Header set Access-Control-Allow-Origin "*"

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Send would-be 404 requests to Craft
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
    RewriteRule (.+) index.php?p=$1 [QSA,L]
</IfModule>
server/web/.htaccess

Why tho?

When we make a request to the update-cart action Craft is looking to see if there's an `orderNumber` parameter in the body, if that doesn't exist it falls back to the cart attached to the session. If neither exist, it creates a brand new cart.

Β 

We need to make sure our headless user is getting the proper session

User Sessions

# Not restrictive enough
Header set Access-Control-Allow-Origin "*"

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Send would-be 404 requests to Craft
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
    RewriteRule (.+) index.php?p=$1 [QSA,L]
</IfModule>
server/web/.htaccess
# Origin must be full URL (our Gatsby app is running at 5000)
Header set Access-Control-Allow-Origin "https://headless.test:5000"

# Turn on allow credentials
Header set Access-Control-Allow-Credentials true

# Allow methods
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"

# Make sure Content-Type and Accept headers are allowed
Header set Access-Control-Allow-Headers "Content-Type, Accept"

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Send would-be 404 requests to Craft
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
    RewriteRule (.+) index.php?p=$1 [QSA,L]
</IfModule>
server/web/.htaccess
import React from "react"
import Layout from "../components/layout"
import { graphql } from "gatsby"
import axios from "axios"
import qs from "qs"

const api = axios.create({
  baseURL: process.env.GATSBY_API_URL,
  withCredentials: true
})

const ProductTemplate = ({ data, location }) => {
  const [cart, setCart] = React.useState({})

  const updateCart = () => {
    api
      .post(
        "/",
        qs.stringify({
          action: "commerce/cart/update-cart",
          qty: 1,
          purchasableId: product.defaultVariant.id,
        }),
        {
          headers: {
            Accept: "application/json",
            "Content-Type": "application/x-www-form-urlencoded",
          },
        }
      )
      .then(response => {
        console.log("response", response) // eslint-disable-line
        const { data } = response
        const { success, cart } = data
        if (success) {
          setCart(cart)
        }
      })
      .catch(err => {
        console.log("err", err) // eslint-disable-line
      })
  }

  return (
    <Layout location={location}>
      <h1>{product.title}</h1>
      <h4>Cart</h4>
      <button type="button" onClick={updateCart}>
        Add To Cart
      </button>
      <div style={{ marginTop: 20 }} />
      <pre>{JSON.stringify(cart, null, 2)}</pre>
    </Layout>
  )
}

export default ProductTemplate

export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      id: alternative_id
      defaultVariant {
        id: alternative_id
        stock
      }
      defaultPrice
      variants {
        id: alternative_id
        stock
      }
      dateUpdated {
        date
      }
    }
  }
`
client/templates/product.js

Persisting Cart

Store Order Number in LocalStorage

When requesting cart, check for order number and use it if it exists

Checkout

client/components/CheckoutForm/index.js
const CreditCardForm = () => {
  const { cart } = useCart()

  const handleSubmit = values => { ... }

  return (
    <Formik initialValues={initialValues} onSubmit={handleSubmit}>
      {() => {...}}
    </Formik>
  )
}
client/components/CheckoutForm/index.js
import ...

/**
 * Values is something like
 * {
 *     firstName: 'Testing',
 *     lastName: 'Testorson',
 *     number: '4242424242424242',
 *     expiry: '08/2020',
 *     cvv: '123'
 * }
 */
const handleSubmit = values => {
  // Replace with however you need to get your payment token
  const token = getPaymentToken(args).then(({ token }) => {
    api
      .post({
        token,
        firstName,
      	lastName,
        gatewayId: 1,
        orderNumber: cart.number,
        email: cart.email,
        action: "commerce/payments/pay"
      })
      .then(() => {
        navigate("/order");
      });
  });
};
server/config/element-api.php
<?php
// Other endpoints ...
'order/<number:.+>.json'        => static function ( $number ) {
  return [
    'elementType' => Order::class,
    'criteria'    => [ 'number' => $number ],
    'one'         => true,
    'transformer' => static function ( Order $order ) {
      return [
        'number'    => $order->number,
        'reference' => $order->reference
      ];
    }
  ];
},
client/pages/order.js
import React from "react"
import Layout from "../components/layout"
import { useCart } from "../hooks/useCart"
import { api } from "../utilities/api"

const Order = () => {
  const [order, setOrder] = React.useState({
    number: null,
    reference: null
  })
  const { cart, getCart } = useCart()

  /**
   * When the order page initially renders,
   * use the current cart to get the order
   */
  React.useEffect(() => {
    api
      .get(`order/${cart.number}.json`)
      .then(({ data }) => {
        setOrder(data)
      })
      .catch(err => {
        console.log(err)
      })
  }, [])

  /**
   * When the order.reference changes
   * if it has a value, get a new cart
   */
  React.useEffect(() => {
    if (order.reference) {
      getCart();
    }
  }, [order.reference]);


  return (
    <Layout>
      <h1>Thank you</h1>
      <pre>{JSON.stringify(order, null, 2)}</pre>
    </Layout>
  )
}

export default Order
client/pages/order.js
import React from "react"
import Layout from "../components/layout"
import { useCart } from "../hooks/useCart"
import { api } from "../utilities/api"

const Order = () => {
  const [order, setOrder] = React.useState({
    number: null,
    reference: null
  })
  const { cart, getCart } = useCart()

  /**
   * When the order page initially renders,
   * use the current cart to get the order
   */
  React.useEffect(() => {...}, [])

  /**
   * When the order.reference changes
   * if it has a value, get a new cart
   */
  React.useEffect(() => {
    if (order.reference) {
      getCart();
    }
  }, [order.reference]);


  return (
    <Layout>
      <h1>Thank you</h1>
      <pre>{JSON.stringify(order, null, 2)}</pre>
    </Layout>
  )
}

export default Order

Deployment & Rebuilding

Craft

  • ​Host on Cloudways

Gatsby

  • Host on Netlify

* Need to be at the same domain so they can share cookies

Webhooks Plugin

Reduce Rebuilds

What's Next?

Dynamically set headers

Add more protection

Wrap-up

Separate content from view layer

Static HTML = Faster / Easier to host

Thank you

https://onedesigncompany.com/

http://brianhanson.net/

Resources

Gatsby Docs:
https://www.gatsbyjs.org/docs/

Β 

Gatsby Source API Server:
https://github.com/thinhle-agilityio/gatsby-source-apiserver

Made with Slides.com