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
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.
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.
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"> </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
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.