(an experiment)
I'm Brian Hanson
@brianjhanson
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
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
];
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
// 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
}
}
}
`;
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
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
# 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
When requesting cart, check for order number and use it if it exists
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
* Need to be at the same domain so they can share cookies
https://onedesigncompany.com/
http://brianhanson.net/
Gatsby Docs:
https://www.gatsbyjs.org/docs/
Β
Gatsby Source API Server:
https://github.com/thinhle-agilityio/gatsby-source-apiserver