Skip to content

Instantly share code, notes, and snippets.

@stefbowerman
Created January 5, 2019 01:25
Show Gist options
  • Save stefbowerman/4ca55323eb491e56932fa148ba94fac3 to your computer and use it in GitHub Desktop.
Save stefbowerman/4ca55323eb491e56932fa148ba94fac3 to your computer and use it in GitHub Desktop.
/**
* AJAX Cart scripts
* ------------------------------------------------------------------------------
*
* This is a bare-bones but completely usable implementation of an AJAX enabled cart
*
* Usage: slate.AjaxCart.init(options);
*
* See the following list of stubbed / incomplete methods that need to be filled in
*
* - AjaxCart.onOpenClick
* - AjaxCart.onCloseClick
* - AjaxCart.open
* - AjaxCart.close
* - AjaxCart.onItemAddFail
* - AjaxCart.buildCart
*
*/
(function($, Handlebars, slate){
if ((typeof ShopifyAPI) === 'undefined') { ShopifyAPI = {}; }
/*============================================================================
API Functions
==============================================================================*/
/**
* AJAX submit an 'add to cart' form
*
* @param {jQuery} $form - jQuery instance of the form
* @return {Promise} - Resolve returns JSON cart | Reject returns an error message
*/
ShopifyAPI.addItemFromForm = function($form) {
var promise = $.Deferred();
$.ajax({
type: 'post',
dataType: 'json',
url: '/cart/add.js',
data: $form.serialize(),
success: function () {
ShopifyAPI.getCart().then(function (data) {
promise.resolve(data);
});
},
error: function () {
promise.reject({
message: 'The quantity you entered is not available.'
});
}
});
return promise;
};
/**
* Retrieve a JSON respresentation of the users cart
*
* @return {Promise} - JSON cart
*/
ShopifyAPI.getCart = function() {
// return $.getJSON('/cart.js');
var promise = $.Deferred();
$.ajax({
type: 'get',
url: '/cart?view=json',
success: function (data) {
var cart = JSON.parse(data);
promise.resolve(cart);
},
error: function () {
promise.reject({
message: 'Could not retrieve cart items'
});
}
});
return promise;
};
/**
* Retrieve a JSON respresentation of the users cart
*
* @return {Promise} - JSON cart
*/
ShopifyAPI.getProduct = function(handle) {
return $.getJSON('/products/' + handle + '.js');
};
/**
* Change the quantity of an item in the users cart
*
* @param {int} variantId - Variant to be adjust
* @param {int} qty - New quantity of the variant
* @return {Promise} - JSON cart
*/
ShopifyAPI.changeItemQuantity = function(variantId, qty) {
return $.ajax({
type: 'post',
dataType: 'json',
url: '/cart/change.js',
data: 'quantity=' + qty + '&id=' + variantId
});
};
/*============================================================================
Ajax Shopify Add To Cart
==============================================================================*/
slate.AjaxCart = (function() {
var $window = $(window);
var $body = $('body');
var selectors = {
container: '[data-ajax-cart-container]',
template: 'script[data-ajax-cart-template]',
trigger: '[data-ajax-cart-trigger]',
close: '[data-ajax-cart-close]',
addForm: 'form[action^="/cart/add"]',
addToCart: '[data-add-to-cart]',
addToCartText: '[data-add-to-cart-text]',
header: '[data-ajax-cart-header]',
body: '[data-ajax-cart-body]',
footer: '[data-ajax-cart-footer]',
item: '[data-ajax-item][data-id][data-qty]',
itemRemove: '[data-ajax-cart-item-remove]',
itemIncrement: '[data-ajax-cart-item-increment]',
itemDecrement: '[data-ajax-cart-item-decrement]',
itemQuantitySelect: 'select[data-ajax-cart-item-quantity]',
cartBadge: '[data-cart-badge]',
cartBadgeCount: '[data-cart-badge-count]'
};
var classes = {
bodyCartOpen: 'ajax-cart-open',
backdrop: 'ajax-cart-backdrop',
backdropVisible: 'is-visible',
cartOpen: 'is-open',
cartBadgeHasItems: 'has-items'
};
/**
* AjaxCart constructor
*
* Adds an `init` method with access to private variables inside the contructor
*/
function AjaxCart() {
this.name = 'ajaxCart';
this.namespace = '.' + this.name;
this.events = {
RENDER: 'render' + this.namespace,
DESTROY: 'destroy' + this.namespace,
SCROLL: 'scroll' + this.namespace,
UPDATE: 'update' + this.namespace // Use this as a global event to hook into whenever the cart changes
};
this.QUANTITY_SELECT_LIMIT = 5;
var initialized = false;
var settings = {
disableAjaxCart: false
};
this.$el = $(selectors.container);
this.$backdrop = null;
this.stateIsOpen = null;
this.transitionEndEvent = slate.utils.whichTransitionEnd();
/**
* Initialize the cart
*
* @param {object} options - see `settings` variable above
*/
this.init = function(options) {
if(initialized) return;
this.settings = $.extend(settings, options);
if(!$(selectors.template).length){
console.warn('['+this.name+'] - Handlebars template required to initialize');
return;
}
this.$container = $(selectors.container);
this.$cartBadge = $(selectors.cartBadge);
this.$cartBadgeCount = $(selectors.cartBadgeCount);
// Compile this once during initialization
this._registerHelpers(Handlebars);
this.template = Handlebars.compile($(selectors.template).html());
// Add the AJAX part
if(!this.settings.disableAjaxCart) {
this._formOverride();
}
// Add event handlers here
$body.on('click', selectors.trigger, this.onTriggerClick.bind(this));
$body.on('click', selectors.close, this.onCloseClick.bind(this));
$body.on('click', selectors.itemRemove, this.onItemRemoveClick.bind(this));
$body.on('click', selectors.itemIncrement, this.onItemIncrementClick.bind(this));
$body.on('click', selectors.itemDecrement, this.onItemDecrementClick.bind(this));
$body.on('change', selectors.itemQuantitySelect, this.onItemQuantitySelectChange.bind(this));
$window.on(this.events.RENDER, this.onCartRender.bind(this));
$window.on(this.events.DESTROY, this.onCartDestroy.bind(this));
// Get the cart data when we initialize the instance
ShopifyAPI.getCart().then(this.buildCart.bind(this));
initialized = true;
return initialized;
};
return this;
}
AjaxCart.prototype = $.extend({}, AjaxCart.prototype, {
/**
* Call this function to AJAX-ify any add to cart forms on the page
*/
_formOverride: function() {
var _this = this;
$body.on('submit', selectors.addForm, function(e) {
e.preventDefault();
var $submitButton = $(e.target).find(selectors.addToCart);
var $submitButtonText = $submitButton.find(selectors.addToCartText);
// Update the submit button text and disable the button so the user knows the form is being submitted
$submitButton.prop('disabled', true);
$submitButtonText.html(theme.strings.adding);
ShopifyAPI.addItemFromForm( $(e.target) )
.then(function(data) {
// Reset button state
$submitButton.prop('disabled', false);
$submitButtonText.html(theme.strings.addToCart);
_this.onItemAddSuccess.call(_this, data);
})
.fail(function(data) {
// Reset button state
$submitButton.prop('disabled', false);
$submitButtonText.html(theme.strings.addToCart);
_this.onItemAddFail.call(_this, data) ;
});
}.bind(this));
},
/**
* Ensure we are working with a valid number
*
* @param {int|string} qty
* @return {int} - Integer quantity. Defaults to 1
*/
_validateQty: function(qty) {
return (parseFloat(qty) == parseInt(qty)) && !isNaN(qty) ? qty : 1;
},
_getItemRowAttributes: function(el) {
var $el = $(el);
var $row = $el.is(selectors.item) ? $el : $el.parents(selectors.item);
return {
$row: $row,
id: $row.data('id'),
qty: this._validateQty($row.data('qty'))
};
},
/**
* Adds helper functions to Handlebars library.
*
* @param {Handlebars} Handlebars - Global instance
*/
_registerHelpers: function(Handlebars) {
var _this = this;
Handlebars.registerHelper('qty-select', function(qty, cartItem) {
var $output = $('<select />');
$output.addClass('form-control form-control--thick');
$output.attr('data-ajax-cart-item-quantity', '');
var limit = _this.QUANTITY_SELECT_LIMIT;
var singularLabel;
var pluralLabel;
qty = parseInt(qty) || 1;
if(cartItem && cartItem.metafields && cartItem.metafields.labels) {
singularLabel = cartItem.metafields.labels.bulk_unit_name_singular || '';
pluralLabel = cartItem.metafields.labels.bulk_unit_name_plural || '';
}
for (var i = 1; i <= limit; i++) {
var $option = $('<option />');
var val = i;
var text = i;
text += ' ' + slate.utils.pluralize(i, singularLabel, pluralLabel);
$option.val(val);
$option.text(text);
$option.attr('selected', (i == qty));
$output.append($option);
}
return $output.get(0).outerHTML;
});
},
addBackdrop: function(callback) {
var _this = this;
var cb = callback || $.noop;
if(this.stateIsOpen) {
this.$backdrop = $(document.createElement('div'));
this.$backdrop.addClass(classes.backdrop)
.appendTo($body);
this.$backdrop.one(this.transitionEndEvent, cb);
this.$backdrop.one('click', this.close.bind(this));
// debug this...
setTimeout(function() {
_this.$backdrop.addClass(classes.backdropVisible);
}, 10);
}
else {
cb();
}
},
removeBackdrop: function(callback) {
var _this = this;
var cb = callback || $.noop;
if(!this.stateIsOpen && this.$backdrop) {
this.$backdrop.one(this.transitionEndEvent, function(){
_this.$backdrop && _this.$backdrop.remove();
_this.$backdrop = null;
cb();
});
setTimeout(function() {
_this.$backdrop.removeClass(classes.backdropVisible);
}, 10);
}
else {
cb();
}
},
/**
* Callback when adding an item is successful
*
* @param {Object} cart - JSON representation of the cart.
*/
onItemAddSuccess: function(cart){
this.buildCart(cart);
this.open();
},
/**
* STUB - Callback when adding an item fails
* @param {Object} data
* @param {string} data.message - error message
*/
onItemAddFail: function(data){
console.log('['+this.name+'] - onItemAddFail');
console.warn('['+this.name+'] - ' + data.message);
},
/**
* Callback for when the cart HTML is rendered to the page
* Allows us to add event handlers for events that don't bubble
*/
onCartRender: function(e) {
slate.utils.chosenSelects(this.$container);
},
/**
* Callback for when the cart HTML is removed from the page
* Allows us to do cleanup on any event handlers applied post-render
*/
onCartDestroy: function(e) {
// console.log('['+this.name+'] - onCartDestroy');
},
/**
* Builds the HTML for the ajax cart.
* Modifies the JSON cart for consumption by our handlebars template
*
* @param {object} cart - JSON representation of the cart. See https://help.shopify.com/themes/development/getting-started/using-ajax-api#get-cart
* @return ??
*/
buildCart: function(cart) {
// Make adjustments to the cart object contents before we pass it off to the handlebars template
cart.total_price = slate.Currency.formatMoney(cart.total_price, theme.moneyFormat);
cart.items.map(function(item){
item.image = slate.Image.getSizedImageUrl(item.image, '200x');
item.price = slate.Currency.formatMoney(item.price, theme.moneyFormat);
var singleUnitQuantity;
var singleUnitQuantityLabel;
var singleUnitQuantityBreakdown;
var bulkUnitQuantityLabel;
var bulkUnitQuantityBreakdown;
if(item.variant_title == "Default Title") {
delete item.variant_title;
}
// If the product is a recharge subscription product
if(item.properties && item.properties.shipping_interval_frequency && item.properties.shipping_interval_unit_type) {
// Add property like "Delivery: Every 15 days"
item.shipping_interval = "deliver every ";
item.shipping_interval += item.properties.shipping_interval_frequency + ' ' + item.properties.shipping_interval_unit_type;
}
// Product isn't the hidden recharge product, but has an associated recharge product
else if(item.metafields && item.metafields.subscriptions && item.metafields.subscriptions.subscription_id){
item.shipping_interval = "one-time purchase";
}
// Add the quantity breakdown if all metafields are available
// This is kind of a pain but we have to check for the existence of all these fields in order to output the correct text
if(item.metafields && item.metafields.labels) {
// Create the single unit quantity breakdown if possible
if(item.metafields.bulk_quantity && item.metafields.bulk_quantity.quantity && item.metafields.labels.single_unit_name_singular) {
singleUnitQuantity = item.quantity * item.metafields.bulk_quantity.quantity;
singleUnitQuantityLabel = slate.utils.pluralize(singleUnitQuantity, item.metafields.labels.single_unit_name_singular, item.metafields.labels.single_unit_name_plural);
singleUnitQuantityBreakdown = singleUnitQuantity + ' ' + singleUnitQuantityLabel;
}
// Create the bulk unit quantity breakdown if possible
if(item.metafields.labels.bulk_unit_name_singular) {
bulkUnitQuantityLabel = slate.utils.pluralize(item.quantity, item.metafields.labels.bulk_unit_name_singular, item.metafields.labels.bulk_unit_name_plural);
bulkUnitQuantityBreakdown = item.quantity + ' ' + bulkUnitQuantityLabel;
}
if(bulkUnitQuantityBreakdown || singleUnitQuantityBreakdown) {
item.quantity_breakdown = '';
// 2 Cases
if(bulkUnitQuantityBreakdown) {
item.quantity_breakdown += bulkUnitQuantityBreakdown;
}
// (24 Bottles)
if(singleUnitQuantityBreakdown) {
item.quantity_breakdown += ' (' + singleUnitQuantityBreakdown + ')';
}
}
}
return item;
});
/**
* You can also use this as an intermediate step to constructing the AJAX cart DOM
* by returning an HTML string and using another function to do the DOM updating
*
* return this.template(cart)
*
* The code below isn't the most elegant way to update the cart but it works...
*/
$window.trigger(this.events.DESTROY);
this.$container.empty().append( this.template(cart) );
$window.trigger(this.events.RENDER);
$window.trigger(this.events.UPDATE);
this.updateCartCount(cart);
},
/**
* Update the cart badge + count here
*
* @param {Object} cart - JSON representation of the cart.
*/
updateCartCount: function(cart) {
this.$cartBadgeCount.html(cart.item_count);
if(cart.item_count) {
this.$cartBadge.addClass(classes.cartBadgeHasItems);
}
else {
this.$cartBadge.removeClass(classes.cartBadgeHasItems);
}
},
/**
* Callback for changing the quantity of an item in the cart
*
* @param {event} e - Input change event
*/
onItemQuantitySelectChange: function(e) {
var $select = $(e.currentTarget);
var attrs = this._getItemRowAttributes($select.get(0));
var qty = this._validateQty( $select.val() );
// We're deleting the item
if(qty == 0) {
attrs.$row.addClass('is-being-removed');
}
ShopifyAPI.changeItemQuantity(attrs.id, qty).then(ShopifyAPI.getCart).then(this.buildCart.bind(this));
},
/**
* Remove the item from the cart. Extract this into a separate method if there becomes more ways to delete an item
*
* @param {event} e - Click event
*/
onItemRemoveClick: function(e) {
e.preventDefault();
var attrs = this._getItemRowAttributes(e.target);
ShopifyAPI.changeItemQuantity(attrs.id, 0).then(ShopifyAPI.getCart).then(this.buildCart.bind(this));
},
/**
* Increase the quantity of an item by 1
*
* @param {event} e - Click event
*/
onItemIncrementClick: function(e) {
e.preventDefault();
console.log('['+this.name+'] - onItemIncrementClick');
var attrs = this._getItemRowAttributes(e.target);
ShopifyAPI.changeItemQuantity(attrs.id, attrs.qty + 1).then(ShopifyAPI.getCart).then(this.buildCart.bind(this));
},
/**
* Decrease the quantity of an item by 1
*
* @param {event} e - Click event
*/
onItemDecrementClick: function(e) {
e.preventDefault();
console.log('['+this.name+'] - onItemDecrementClick');
var attrs = this._getItemRowAttributes(e.target);
var newQty = (attrs.qty < 1 ? 0 : attrs.qty - 1);
ShopifyAPI.changeItemQuantity(attrs.id, newQty).then(ShopifyAPI.getCart).then(this.buildCart.bind(this));
},
/**
* Click the 'ajaxCart - trigger' selector
*
* @param {event} e - Click event
*/
onTriggerClick: function(e) {
e.preventDefault();
this.toggleVisibility();
},
/**
* Click the 'ajaxCart - close' selector
*
* @param {event} e - Click event
*/
onCloseClick: function(e) {
e.preventDefault();
console.log('['+this.name+'] - onCloseClick');
// Do any cleanup before closing the cart
this.close();
},
/**
* Opens / closes the cart depending on state
*
*/
toggleVisibility: function() {
return this.stateIsOpen ? this.close() : this.open();
},
/**
* Check the open / closed state of the cart
*
* @return {bool}
*/
isOpen: function() {
return this.stateIsOpen;
},
/**
* Returns true is the cart is closed.
*
* @return {bool}
*/
isClosed: function() {
return !this.stateIsOpen;
},
/**
* Code for opening the cart
*/
open: function() {
if(this.stateIsOpen) return;
this.stateIsOpen = true;
$body.addClass(classes.bodyCartOpen);
this.$el.addClass(classes.cartOpen);
this.addBackdrop();
},
/**
* Code for closing the cart
*/
close: function() {
if(!this.stateIsOpen) return;
this.stateIsOpen = false;
this.$el.removeClass(classes.cartOpen);
this.removeBackdrop(function() {
$body.removeClass(classes.bodyCartOpen);
});
}
});
return AjaxCart;
})();
})(jQuery, Handlebars, slate);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment