UNPKG

@nguyenmv2/buy-button

Version:

BuyButton.js allows merchants to build Shopify interfaces into any website

506 lines (458 loc) 15.7 kB
import merge from '../utils/merge'; import Component from '../component'; import CartToggle from './toggle'; import Template from '../template'; import Checkout from './checkout'; import formatMoney from '../utils/money'; import CartView from '../views/cart'; import CartUpdater from '../updaters/cart'; import {addClassToElement} from '../utils/element-class'; import {removeTrapFocus} from '../utils/focus'; export const NO_IMG_URL = '//sdks.shopifycdn.com/buy-button/latest/no-image.jpg'; const LINE_ITEM_TARGET_SELECTIONS = ['ENTITLED', 'EXPLICIT']; const CART_TARGET_SELECTION = 'ALL'; const CHECKOUT_INPUT_FIELDS = ['presentmentCurrencyCode']; /** * Renders and cart embed. * @extends Component. */ export default class Cart extends Component { /** * create Cart. * @param {Object} config - configuration object. * @param {Object} props - data and utilities passed down from UI instance. */ constructor(config, props) { super(config, props); this.addVariantToCart = this.addVariantToCart.bind(this); this.childTemplate = new Template(this.config.lineItem.templates, this.config.lineItem.contents, this.config.lineItem.order); this.node = config.node || document.body.appendChild(document.createElement('div')); this.isVisible = this.options.startOpen; this.lineItemCache = []; this.moneyFormat = this.globalConfig.moneyFormat; this.checkout = new Checkout(this.config); this.checkoutConfig = this.config.checkout; const toggles = this.globalConfig.toggles || [{ node: this.node.parentNode.insertBefore(document.createElement('div'), this.node), }]; this.toggles = toggles.map((toggle) => { return new CartToggle(merge({}, config, toggle), Object.assign({}, this.props, {cart: this})); }); this.updater = new CartUpdater(this); this.view = new CartView(this); } createToggles(config) { this.toggles = this.toggles.concat(config.toggles.map((toggle) => { return new CartToggle(merge({}, config, toggle), Object.assign({}, this.props, {cart: this})); })); return Promise.all(this.toggles.map((toggle) => { return toggle.init({lineItems: this.lineItems}); })); } /** * get key for configuration object. * @return {String} */ get typeKey() { return 'cart'; } /** * get events to be bound to DOM. * @return {Object} */ get DOMEvents() { return merge({}, { [`click ${this.selectors.cart.close}`]: this.props.closeCart.bind(this), [`click ${this.selectors.lineItem.quantityIncrement}`]: this.onQuantityIncrement.bind(this, 1), [`click ${this.selectors.lineItem.quantityDecrement}`]: this.onQuantityIncrement.bind(this, -1), [`click ${this.selectors.cart.button}`]: this.onCheckout.bind(this), [`blur ${this.selectors.lineItem.quantityInput}`]: this.onQuantityBlur.bind(this), [`blur ${this.selectors.cart.note}`]: this.setNote.bind(this), }, this.options.DOMEvents); } /** * get cart line items. * @return {Array} HTML */ get lineItems() { return this.model ? this.model.lineItems : []; } get checkoutCurrency() { return this.sanitizedCheckoutConfig && this.sanitizedCheckoutConfig.presentmentCurrencyCode; } /** * get HTML for cart line items. * @return {String} HTML */ get lineItemsHtml() { return this.lineItemCache.reduce((acc, lineItem) => { const data = Object.assign({}, lineItem, this.options.viewData); let price; if (this.checkoutCurrency) { price = data.variant.presentmentPrices.find( (priceObj) => priceObj.price.currencyCode === this.checkoutCurrency ).price.amount; } else { price = data.variant.priceV2.amount; } const fullPrice = price * data.quantity; const formattedPrice = formatMoney(fullPrice, this.moneyFormat); const discountAllocations = data.discountAllocations; const {discounts, totalDiscount} = discountAllocations.reduce((discountAcc, discount) => { const targetSelection = discount.discountApplication.targetSelection; if (LINE_ITEM_TARGET_SELECTIONS.indexOf(targetSelection) > -1) { const discountAmount = discount.allocatedAmount.amount; const discountDisplayText = discount.discountApplication.title || discount.discountApplication.code; discountAcc.totalDiscount += discountAmount; discountAcc.discounts.push({discount: `${discountDisplayText} (-${formatMoney(discountAmount, this.moneyFormat)})`}); } return discountAcc; }, { discounts: [], totalDiscount: 0, }); data.discounts = discounts.length > 0 ? discounts : null; data.formattedFullPrice = totalDiscount > 0 ? formattedPrice : null; data.formattedActualPrice = formatMoney(fullPrice - totalDiscount, this.moneyFormat); data.formattedPrice = formattedPrice; data.classes = this.classes; data.lineItemImage = this.imageForLineItem(data); data.variantTitle = data.variant.title === 'Default Title' ? '' : data.variant.title; return acc + this.childTemplate.render({data}, (output) => `<div id="${lineItem.id}" class=${this.classes.lineItem.lineItem}>${output}</div>`); }, ''); } /** * get data to be passed to view. * @return {Object} viewData object. */ get viewData() { const modelData = this.model || {}; return merge(modelData, this.options.viewData, { text: this.options.text, classes: this.classes, lineItemsHtml: this.lineItemsHtml, isEmpty: this.isEmpty, formattedTotal: this.formattedTotal, discounts: this.cartDiscounts, contents: this.options.contents, cartNote: this.cartNote, }); } /** * get formatted cart subtotal based on moneyFormat * @return {String} */ get formattedTotal() { if (!this.model) { return formatMoney(0, this.moneyFormat); } const total = this.options.contents.discounts ? this.model.subtotalPriceV2.amount : this.model.lineItemsSubtotalPrice.amount; return formatMoney(total, this.moneyFormat); } get cartDiscounts() { if (!this.options.contents.discounts || !this.model) { return []; } return this.model.discountApplications.reduce((discountArr, discount) => { if (discount.targetSelection === CART_TARGET_SELECTION) { let discountValue = 0; if (discount.value.amount) { discountValue = discount.value.amount; } else if (discount.value.percentage) { discountValue = (discount.value.percentage / 100) * this.model.lineItemsSubtotalPrice.amount; } if (discountValue > 0) { const discountDisplayText = discount.title || discount.code; discountArr.push({text: discountDisplayText, amount: `-${formatMoney(discountValue, this.moneyFormat)}`}); } } return discountArr; }, []); } /** * whether cart is empty * @return {Boolean} */ get isEmpty() { if (!this.model) { return true; } return this.model.lineItems.length < 1; } get cartNote() { return this.model && this.model.note; } get wrapperClass() { return this.isVisible ? 'is-active' : ''; } get localStorageCheckoutKey() { return `${this.props.client.config.storefrontAccessToken}.${this.props.client.config.domain}.checkoutId`; } imageForLineItem(lineItem) { const imageSize = 180; const imageOptions = { maxWidth: imageSize, maxHeight: imageSize, }; if (lineItem.variant.image) { return this.props.client.image.helpers.imageForSize(lineItem.variant.image, imageOptions); } else { return NO_IMG_URL; } } /** * sets model to null and removes checkout from localStorage * @return {Promise} promise resolving to the cart model */ removeCheckout() { this.model = null; localStorage.removeItem(this.localStorageCheckoutKey); return this.model; } /** * get model data either by calling client.createCart or loading from localStorage. * @return {Promise} promise resolving to cart instance. */ fetchData() { const checkoutId = localStorage.getItem(this.localStorageCheckoutKey); if (checkoutId) { return this.props.client.checkout.fetch(checkoutId).then((checkout) => { this.model = checkout; if (checkout.completedAt) { return this.removeCheckout(); } return this.sanitizeCheckout(checkout).then((newCheckout) => { this.updateCache(newCheckout.lineItems); return newCheckout; }); }).catch(() => { return this.removeCheckout(); }); } else { return Promise.resolve(null); } } sanitizeCheckout(checkout) { const lineItemsToDelete = checkout.lineItems.filter((item) => !item.variant); if (!lineItemsToDelete.length) { return Promise.resolve(checkout); } const lineItemIds = lineItemsToDelete.map((item) => item.id); return this.props.client.checkout.removeLineItems(checkout.id, lineItemIds).then((newCheckout) => { return newCheckout; }); } fetchMoneyFormat() { return this.props.client.shop.fetchInfo().then((res) => { return res.moneyFormat; }); } /** * initializes component by creating model and rendering view. * Creates and initalizes toggle component. * @param {Object} [data] - data to initialize model with. * @return {Promise} promise resolving to instance. */ init(data) { if (!this.moneyFormat) { this.fetchMoneyFormat().then((moneyFormat) => { this.moneyFormat = moneyFormat; }); } return super.init(data) .then((cart) => { return this.toggles.map((toggle) => { const lineItems = cart.model ? cart.model.lineItems : []; return toggle.init({lineItems}); }); }).then(() => this); } destroy() { super.destroy(); this.toggles.forEach((toggle) => toggle.destroy()); } /** * closes cart */ close() { this.isVisible = false; this.view.render(); removeTrapFocus(this.view.wrapper); } /** * opens cart */ open() { this.isVisible = true; this.view.render(); this.view.setFocus(); } /** * toggles cart visibility * @param {Boolean} visible - desired state. */ toggleVisibility(visible) { this.isVisible = visible || !this.isVisible; this.view.render(); if (this.isVisible) { this.view.setFocus(); } } onQuantityBlur(evt, target) { this.setQuantity(target, () => parseInt(target.value, 10)); } onQuantityIncrement(qty, evt, target) { this.setQuantity(target, (prevQty) => prevQty + qty); } onCheckout() { this._userEvent('openCheckout'); this.props.tracker.track('Open cart checkout', {}); this.checkout.open(this.model.webUrl); } /** * set quantity for a line item. * @param {Object} target - DOM node of line item * @param {Function} fn - function to return new quantity given currrent quantity. */ setQuantity(target, fn) { const id = target.getAttribute('data-line-item-id'); const item = this.model.lineItems.find((lineItem) => lineItem.id === id); const newQty = fn(item.quantity); return this.props.tracker.trackMethod(this.updateItem.bind(this), 'Update Cart', this.cartItemTrackingInfo(item, newQty))(id, newQty); } setNote(evt) { const note = evt.target.value; return this.props.client.checkout.updateAttributes(this.model.id, {note}).then((checkout) => { this.model = checkout; return checkout; }); } /** * set cache using line items. * @param {Array} lineItems - array of GraphModel line item objects. */ updateCache(lineItems) { const cachedLineItems = this.lineItemCache.reduce((acc, item) => { acc[item.id] = item; return acc; }, {}); this.lineItemCache = lineItems.map((item) => { return Object.assign({}, cachedLineItems[item.id], item); }); return this.lineItemCache; } /** * update cached line item. * @param {Number} id - lineItem id. * @param {Number} qty - quantity for line item. */ updateCacheItem(lineItemId, quantity) { if (this.lineItemCache.length === 0) { return; } const lineItem = this.lineItemCache.find((item) => { return lineItemId === item.id; }); lineItem.quantity = quantity; this.view.render(); } /** * update line item. * @param {Number} id - lineItem id. * @param {Number} qty - quantity for line item. */ updateItem(id, quantity) { this._userEvent('updateItemQuantity'); const lineItem = {id, quantity}; const lineItemEl = this.view.document.getElementById(id); if (lineItemEl) { const quantityEl = lineItemEl.getElementsByClassName(this.classes.lineItem.quantity)[0]; if (quantityEl) { addClassToElement('is-loading', quantityEl); } } return this.props.client.checkout.updateLineItems(this.model.id, [lineItem]).then((checkout) => { this.model = checkout; this.updateCache(this.model.lineItems); this.toggles.forEach((toggle) => toggle.view.render()); if (quantity > 0) { this.view.render(); } else { this.view.animateRemoveNode(id); } return checkout; }); } /** * add variant to cart. * @param {Object} variant - variant object. * @param {Number} [quantity=1] - quantity to be added. */ addVariantToCart(variant, quantity = 1, openCart = true) { if (quantity <= 0) { return null; } if (openCart) { this.open(); } const lineItem = {variantId: variant.id, quantity}; if (this.model) { return this.props.client.checkout.addLineItems(this.model.id, [lineItem]).then((checkout) => { this.model = checkout; this.updateCache(this.model.lineItems); this.view.render(); this.toggles.forEach((toggle) => toggle.view.render()); this.view.setFocus(); return checkout; }); } else { const input = { lineItems: [ lineItem, ], ...this.sanitizedCheckoutConfig, }; return this.props.client.checkout.create(input).then((checkout) => { localStorage.setItem(this.localStorageCheckoutKey, checkout.id); this.model = checkout; this.updateCache(this.model.lineItems); this.view.render(); this.toggles.forEach((toggle) => toggle.view.render()); this.view.setFocus(); return checkout; }); } } get sanitizedCheckoutConfig() { return Object.keys(this.checkoutConfig).reduce((result, key) => { if (!CHECKOUT_INPUT_FIELDS.includes(key)) { return result; } return {...result, [key]: this.checkoutConfig[key]}; }, {}); } /** * Remove all lineItems in the cart */ empty() { const lineItemIds = this.model.lineItems ? this.model.lineItems.map((item) => item.id) : []; return this.props.client.checkout.removeLineItems(this.model.id, lineItemIds).then((checkout) => { this.model = checkout; this.view.render(); this.toggles.forEach((toggle) => toggle.view.render()); return checkout; }); } /** * get info about line item to be sent to tracker * @return {Object} */ cartItemTrackingInfo(item, quantity) { return { id: item.variant.id, variantName: item.variant.title, productId: item.variant.product.id, name: item.title, price: item.variant.priceV2.amount, prevQuantity: item.quantity, quantity: parseFloat(quantity), sku: null, }; } }