Dusty Candland | Thu Jul 25 2019 StimulusJS and Shopify API; Making a Cart

StimulusJS and Shopify API; Making a Cart

Now that we're pulling data from Shopify we need a way for people to actually buy! I'm using StimulusJS for the client side and the Shopify API to create a cart and add products to it.

Check out the previous posts and the example code on GitHub

StimulusJS

I like StimulusJS because it uses the DOM to store state and can be used to progressively enhance pages. We won't handle the progressive stuff since we don't have a server to fall back on, but it could be done with functions.

Setup

npm -i -D stimulus

We need to initialize StimulusJS in src/_assets/js/index.js.

import { Application } from 'stimulus'

import CartController from './controllers/cart_controller.js'
import ProductController from './controllers/product_controller.js'

window.$ = window.jQuery = $
window.bootstrap = bootstrap
window.popper = popper

const application = Application.start()
application.register('cart', CartController)
application.register('product', ProductController)

We'll add the controllers next.

Create the Cart

The cart will show the products, quantities, and prices. We'll have a little summary view and clicks open a detailed view. Since we already have Nunjucks for the server side, we'll use that for the client side too.

First we'll layout some HTML in src/_includes/header.njk.

<div class="col" data-controller="cart">
<div data-toggle="collapse" class="btn-cart" aria-expanded="false" aria-controls="collapseCart" data-target="#collapseCart">
<div data-target="cart.summary">
<i class="fa fa-fw fa-shopping-cart"></i>
</div>
</div>

<div class="collapse" id="collapseCart">
<div data-target="cart.details">
Cart currently empty! Add some stuff!
</div>
</div>
</div>

The data- attributes are what wires the HTML to the controllers.

Next we'll add the templates for the detail and summary.

// src/_assets/js/templates/cart_summary.js

const nunjucks = require('nunjucks')

const tmp = `
<i class="fa fa-fw fa-shopping-cart"></i>
{{ items }} Items
-
\${{ subtotalPrice }}
<a href="{{ webUrl }}" class="btn btn-xs btn-outline-secondary float-right">Checkout</button>
`


var cartSummaryTemplate = nunjucks.compile(tmp)

export default cartSummaryTemplate
// src/_assets/js/templates/cart_details.js

const nunjucks = require('nunjucks')

const tmp = `
<table class="table table-sm table-borderless">
{% for item in items %}
<tr class="cart-item">
<th>
{{ item.title }}{% if item.variant.title != "Default Title" %}: {{ item.variant.title }}{% endif %}
</th>
<td class="text-right">\${{ item.variant.price }}</td>
<td class="text-right">
x {{ item.quantity }}
<a href="#" data-action="cart#increment" data-id="{{ item.id }}" data-quantity="{{ item.quantity }}"><i class="fa fa-caret-up"></i></a>
<a href="#" data-action="cart#decrement" data-id="{{ item.id }}" data-quantity="{{ item.quantity }}"><i class="fa fa-caret-down"></i></a>
</td>
<td class="text-right">\${{ currency(item.variant.price * item.quantity) }}</td>
<td class="text-right"><a href="#" data-action="cart#remove" data-id="{{ item.id }}"><i class="fa fa-trash"></i></a></td>
</tr>
{% endfor %}
<tr class="cart-total">
<th colspan="3" class="text-right">Subtotal:</th>
<td class="text-right">\${{ subtotalPrice }}</td>
<td class="text-right">&nbsp;</td>
</tr>
</table>
`


var cartDetailsTemplate = nunjucks.compile(tmp)

export default cartDetailsTemplate

With those in place we finally setup the controllers.

// src/_assets/js/controllers/cart_controller.js

import { Controller } from 'stimulus'
import Client from 'shopify-buy'
import cartSummaryTemplate from '../templates/cart_summary.js'
import cartDetailsTemplate from '../templates/cart_details.js'

export default class extends Controller {
static targets = [ "summary", "details" ]

initialize() {
this.client = Client.buildClient({
domain: process.env.SHOPIFY_DOMAIN,
storefrontAccessToken: process.env.SHOPIFY_ACCESS_TOKEN
})

if (this.checkoutId) {
this.client.checkout.fetch(this.checkoutId).then((checkout) => {
this.setCheckoutId(checkout.id)
this.render(checkout)
});
} else {
this.client.checkout.create().then((checkout) => {
// Do something with the checkout
this.setCheckoutId(checkout.id)
this.render(checkout)
});
}

this.addToCart = this.addToCart.bind(this)
}

get checkoutId() {
return window.localStorage.getItem('checkoutId');
}

setCheckoutId(id) {
window.localStorage.setItem('checkoutId', id)
return id
}

connect() {
$(this.application).on("cart:add", this.addToCart)
}

disconnect() {
$(this.application).off("cart:add", this.addToCart)
}

addToCart(e, props) {
const cattrs = JSON.parse(props.options).map((h) => {
return {key: h.name, value: h.value};
})

const lineItemsToAdd = [
{
variantId: props.id,
quantity: 1,
customAttributes: cattrs
}
];

this.client.checkout.addLineItems(this.checkoutId, lineItemsToAdd).then((checkout) => {
this.render(checkout)
});
}

decrement(e) {
e.preventDefault()
const lineItemsToUpdate = [{
id: e.target.parentElement.getAttribute("data-id"),
quantity: parseInt(e.target.parentElement.getAttribute("data-quantity")) - 1
}];

this.client.checkout.updateLineItems(this.checkoutId, lineItemsToUpdate).then((checkout) => {
this.render(checkout)
});
}

increment(e) {
e.preventDefault()
const lineItemsToUpdate = [{
id: e.target.parentElement.getAttribute("data-id"),
quantity: parseInt(e.target.parentElement.getAttribute("data-quantity")) + 1
}];

this.client.checkout.updateLineItems(this.checkoutId, lineItemsToUpdate).then((checkout) => {
this.render(checkout)
});
}

remove(e) {
e.preventDefault()
const lineItemIdsToRemove = [
e.target.parentElement.getAttribute("data-id")
];

this.client.checkout.removeLineItems(this.checkoutId, lineItemIdsToRemove).then((checkout) => {
this.render(checkout)
});
}

render(checkout) {
this.checkout = checkout
this.summaryTarget.innerHTML = cartSummaryTemplate.render({
items: this.checkout.lineItems.reduce((m, item) => { return m + item.quantity }, 0),
subtotalPrice: this.checkout.subtotalPrice,
webUrl: this.checkout.webUrl,
})
this.detailsTarget.innerHTML = cartDetailsTemplate.render({
items: this.checkout.lineItems,
subtotalPrice: this.checkout.subtotalPrice,
currency: function(value) { return value.toFixed(2); }
})
}
}

There is a lot going on here! Basically, we're just connecting the site to Shopify and adding handlers for adding, removing, and changing quantity. I'm storing the cart ID in local storage so as long as they use the same browser the cart will be saved.

I think the process.env.SHOPIFY_ACCESS_TOKEN & SHOPIFY_DOMAIN will actually have to be hard coded

Add Products to the Cart

The add to cart stuff follows the same pattern.

We're getting this data from the Eleventy Collections on build.

In the src/products/products.njk file I have something like the following, most of this is for product display, but attributes also add some complexity.

<article class="product" data-controller="product">
<h1>{{product.title}}</h1>

<div class="row">
<div class="col-12 col-lg-7">

<div id="carouselExampleControls" class="carousel slide" data-ride="carousel">
<div class="carousel-inner">
{% for edge in product.images.edges %}
<div class="carousel-item {{ "active" if loop.first }}">
<img src="{{ edge.node.originalSrc }}"
alt="{{ edge.node.altText }}"
class="d-block w-100" />

</div>
{% endfor %}
</div>
{% if product.images.edges | length > 1 %}
<a class="carousel-control-prev text-primary" href="#carouselExampleControls" role="button" data-slide="prev">
<i class="fa fa-2x fa-chevron-left"></i>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next text-primary" href="#carouselExampleControls" role="button" data-slide="next">
<i class="fa fa-2x fa-chevron-right"></i>
<span class="sr-only">Next</span>
</a>
{% endif %}
</div>

<div class="social text-center my-3">
<a href="https://twitter.com/intent/tweet?text={{ product.title | urlencode }}&url={{ page.url | prepend(site.url) | urlencode }}&via={{ site.social.twitter }}&hashtags=11ty,shopify" target="_blank">
<i class="fa fa-fw fa-2x fa-twitter text-black-50"></i>
</a>
<a href="https://www.facebook.com/dialog/share?app_id={{ site.social.fb_appid }}&display=popup&href=&href={{ page.url | prepend(site.url)| urlencode }}" target="_blank">
<i class="fa fa-fw fa-2x fa-facebook text-black-50"></i>
</a>
<a href="https://www.pinterest.com/pin/create/button/?description={{ product.description | urlencode }}&url={{ page.url | prepend(site.url) | urlencode }}&media={{ product | productImage() }}" target="_blank">
<i class="fa fa-fw fa-2x fa-pinterest text-black-50"></i>
</a>
</div>
</div>

<div class="col-12 col-lg-5">
<input type="hidden" name="productId" data-target="product.productId" value="{{ product.id }}"/>

{% set minPrice = product.priceRange.minVariantPrice.amount | fmtPrice %}
{% set maxPrice = product.priceRange.maxVariantPrice.amount | fmtPrice %}

<p>
{% if minPrice == maxPrice %}
$<span data-target="product.variantPrice">{{ minPrice | fmtPrice }}</span>
{% else %}
$<span data-target="product.variantPrice">{{ minPrice | fmtPrice }} - {{ maxPrice | fmtPrice }}</span>
{% endif %}
</p>

{% if (product.variants.edges | length) > 1 %}
{% set options = product.variants.edges | prodOptions %}

{% for name, values in options %}
<p>
{% for value in values %}
<button class="btn btn-sm btn-outline-secondary" data-action="product#updateOptions" data-name="{{name}}" data-value="{{value}}">{{value}}</button>
{% endfor %}
<br/>{{name}}: <span class="opt-value"></span>
</p>
{% endfor %}

<button class="btn btn-sm btn-primary"
data-target="product.cartButton"
data-action="product#add" disabled>
Add to Cart</button>

{% else %}
{% set edge = product.variants.edges | first %}

{% if product.availableForSale %}
<button class="btn btn-sm btn-primary"
data-target="product.cartButton"
data-action="product#add"
data-id="{{edge.node.id}}"
data-options='{{ edge.node.selectedOptions | dump }}'>

{% if product.tags | isPreorder %}
Preorder!
{% else %}
Add to Cart
{% endif %}
</button>
{% endif %}
{% if (not product.availableForSale) or (product.tags | isPreorder) %}
<button class="btn btn-sm btn-secondary"
data-target="product.notifyMe"
data-action="product#notify" >
Notify Me!</button>
{% endif %}

{% endif %}

<hr/>

<div class="product-description">{{ product.descriptionHtml | safe }}</div>

{% if product.tags | isPreorder %}
<aside class="preorder">
</aside>
{% endif %}

<hr/>

<div class="product-tags">{{ product.tags | join(", ") }}</div>
</div>

</div>
</article>

There aren't any templates for this, just the controller.

// src/_assets/js/controllers/product_controller.js

import { Controller } from 'stimulus'
import Client from 'shopify-buy'

export default class extends Controller {
static targets = [ "productId", "variantPrice", "cartButton" ]

initialize() {
// console.log("products!")

this.client = Client.buildClient({
domain: process.env.SHOPIFY_DOMAIN,
storefrontAccessToken: process.env.SHOPIFY_ACCESS_TOKEN
})

this.client.product.fetch(this.productId).then(product => this.product = product)

this.options = {}
}

add(event) {
event.preventDefault()
// console.log("Adding")
const data = {
id: event.target.getAttribute('data-id'),
options: event.target.getAttribute('data-options')
}
// console.log(data)

const label = this.cartButtonTarget.innerHTML;
this.cartButtonTarget.innerHTML = "Added!"

window.setTimeout(() => {
this.cartButtonTarget.innerHTML = label
}, 2500)

$(this.application).trigger("cart:add", data)
}

notify(event) {
event.preventDefault()
window.location = window.location + "#notify"
}

updateOptions(event) {
event.preventDefault()
const el = event.target
const name = el.getAttribute('data-name')
const value = el.getAttribute('data-value')
this.options = Object.assign(this.options, {[name]: value})
// console.log(this.options)

this.getAllSiblings(el, (elem) => { return elem.nodeName.toUpperCase() == 'BUTTON'}).map((elem) => {
elem.classList.remove('btn-secondary')
elem.classList.add('btn-outline-secondary')
})

el.classList.remove('btn-outline-secondary');
el.classList.add('btn-secondary');

el.parentNode.getElementsByClassName('opt-value')[0].innerHTML = value

const selectedVariant = this.client.product.helpers.variantForOptions(this.product, this.options);
// console.log(selectedVariant)

if (selectedVariant) {
if (selectedVariant.available) {
const variableId = selectedVariant.id
const selectedOptions = selectedVariant.selectedOptions

this.cartButtonTarget.setAttribute('data-id', variableId)
this.cartButtonTarget.setAttribute('data-options', JSON.stringify(selectedOptions))
this.cartButtonTarget.setAttribute('disabled', false) // HACK FOR IE EDGE
this.cartButtonTarget.removeAttribute('disabled')
// console.log(this.variantPriceTarget)
this.variantPriceTarget.innerHTML = selectedVariant.price
} else {
this.cartButtonTarget.setAttribute('disabled', true)
}
} else {
this.cartButtonTarget.setAttribute('disabled', true)
}

}

getAllSiblings(elem, filter) {
var sibs = [];
elem = elem.parentNode.firstChild;
do {
if (elem.nodeType === 3) continue; // text node
if (!filter || filter(elem)) sibs.push(elem);
} while (elem = elem.nextSibling)
return sibs;
}


get productId() {
return this.productIdTarget.value
}
}

Most of the code here is to handle attributes as well. I'm also using jQuery events to connect the add to cart button to the cart_controller.

The connect and disconnect methods in the cart_controller.js file setup the listeners. The addToCart action here triggers the event.

Now we should be able to add products and have the cart updated! The checkout button goes to the Shopify Domain to complete the purchase.