@nguyenmv2/buy-button
Version:
BuyButton.js allows merchants to build Shopify interfaces into any website
859 lines (763 loc) • 25.6 kB
JavaScript
import merge from '../utils/merge';
import Component from '../component';
import Template from '../template';
import Checkout from './checkout';
import windowUtils from '../utils/window-utils';
import formatMoney from '../utils/money';
import normalizeConfig from '../utils/normalize-config';
import browserFeatures from '../utils/detect-features';
import getUnitPriceBaseUnit from '../utils/unit-price';
import ProductView from '../views/product';
import ProductUpdater from '../updaters/product';
function isFunction(obj) {
return Boolean(obj && obj.constructor && obj.call && obj.apply);
}
function isPseudoSelector(key) {
return key.charAt(0) === ':';
}
function isMedia(key) {
return key.charAt(0) === '@';
}
const ENTER_KEY = 13;
const propertiesWhitelist = [
'background',
'background-color',
'border',
'border-radius',
'color',
'border-color',
'border-width',
'border-style',
'transition',
'text-transform',
'text-shadow',
'box-shadow',
'font-size',
'font-family',
];
function whitelistedProperties(selectorStyles) {
return Object.keys(selectorStyles).reduce((filteredStyles, propertyName) => {
if (isPseudoSelector(propertyName) || isMedia(propertyName)) {
filteredStyles[propertyName] = whitelistedProperties(selectorStyles[propertyName]);
return filteredStyles;
}
if (propertiesWhitelist.indexOf(propertyName) > -1) {
filteredStyles[propertyName] = selectorStyles[propertyName];
}
return filteredStyles;
}, {});
}
/**
* Renders and fetches data for product embed.
* @extends Component.
*/
export default class Product extends Component {
/**
* create Product.
* @param {Object} config - configuration object.
* @param {Object} props - data and utilities passed down from UI instance.
*/
constructor(config, props) {
// eslint-disable-next-line no-param-reassign
config = normalizeConfig(config);
super(config, props);
this.typeKey = 'product';
this.defaultStorefrontVariantId = config.storefrontVariantId;
this.cachedImage = null;
this.childTemplate = new Template(this.config.option.templates, this.config.option.contents, this.config.option.order);
this.cart = null;
this.modal = null;
this.imgStyle = '';
this.selectedQuantity = 1;
this.selectedVariant = {};
this.selectedOptions = {};
this.selectedImage = null;
this.updater = new ProductUpdater(this);
this.view = new ProductView(this);
}
/**
* determines when image src should be updated
* @return {Boolean}
*/
get shouldUpdateImage() {
return !this.cachedImage || (this.image && this.image.src !== this.cachedImage);
}
/**
* get image for product and cache it. Return caches image if shouldUpdateImage is false.
* @return {Object} image objcet.
*/
get currentImage() {
if (this.shouldUpdateImage) {
this.cachedImage = this.image;
}
return this.cachedImage;
}
/**
* get image for selected variant and size based on options or layout.
* @return {Object} image object.
*/
get image() {
const DEFAULT_IMAGE_SIZE = 480;
const MODAL_IMAGE_SIZE = 550;
if (!(this.selectedVariant || this.options.contents.imgWithCarousel)) {
return null;
}
let imageSize;
if (this.options.width && this.options.width.slice(-1) === '%') {
imageSize = 1000;
} else {
imageSize = parseInt(this.options.width, 10) || DEFAULT_IMAGE_SIZE;
}
let id;
let src;
let srcLarge;
let srcOriginal;
let altText;
const imageOptions = {
maxWidth: imageSize,
maxHeight: imageSize * 1.5,
};
const imageOptionsLarge = {
maxWidth: MODAL_IMAGE_SIZE,
maxHeight: MODAL_IMAGE_SIZE * 1.5,
};
if (this.selectedImage) {
id = this.selectedImage.id;
src = this.props.client.image.helpers.imageForSize(this.selectedImage, imageOptions);
srcLarge = this.props.client.image.helpers.imageForSize(this.selectedImage, imageOptionsLarge);
srcOriginal = this.selectedImage.src;
altText = this.imageAltText(this.selectedImage.altText);
} else if (this.selectedVariant.image == null && this.model.images[0] == null) {
id = null;
src = '';
srcLarge = '';
srcOriginal = '';
altText = '';
} else if (this.selectedVariant.image == null) {
id = this.model.images[0].id;
src = this.model.images[0].src;
srcLarge = this.props.client.image.helpers.imageForSize(this.model.images[0], imageOptionsLarge);
srcOriginal = this.model.images[0].src;
altText = this.imageAltText(this.model.images[0].altText);
} else {
id = this.selectedVariant.image.id;
src = this.props.client.image.helpers.imageForSize(this.selectedVariant.image, imageOptions);
srcLarge = this.props.client.image.helpers.imageForSize(this.selectedVariant.image, imageOptionsLarge);
srcOriginal = this.selectedVariant.image.src;
altText = this.imageAltText(this.selectedVariant.image.altText);
}
return {id, src, srcLarge, srcOriginal, altText};
}
/**
* get formatted cart subtotal based on moneyFormat
* @return {String}
*/
get formattedPrice() {
if (!this.selectedVariant) {
return '';
}
return formatMoney(this.selectedVariant.priceV2.amount, this.globalConfig.moneyFormat);
}
/**
* get formatted cart subtotal based on moneyFormat
* @return {String}
*/
get formattedCompareAtPrice() {
if (!this.hasCompareAtPrice) {
return '';
}
return formatMoney(this.selectedVariant.compareAtPriceV2.amount, this.globalConfig.moneyFormat);
}
/**
* get whether unit price string should be displayed
* @return {Boolean}
*/
get showUnitPrice() {
if (!this.selectedVariant || !this.selectedVariant.unitPrice || !this.options.contents.unitPrice) {
return false;
}
return true;
}
/**
* get formatted variant unit price amount based on moneyFormat
* @return {String}
*/
get formattedUnitPrice() {
if (!this.showUnitPrice) {
return '';
}
return formatMoney(this.selectedVariant.unitPrice.amount, this.globalConfig.moneyFormat);
}
/**
* get formatted variant unit price base unit
* @return {String}
*/
get formattedUnitPriceBaseUnit() {
if (!this.showUnitPrice) {
return '';
}
const unitPriceMeasurement = this.selectedVariant.unitPriceMeasurement;
return getUnitPriceBaseUnit(unitPriceMeasurement.referenceValue, unitPriceMeasurement.referenceUnit);
}
/**
* get data to be passed to view.
* @return {Object} viewData object.
*/
get viewData() {
return Object.assign({}, this.model, this.options.viewData, {
classes: this.classes,
contents: this.options.contents,
text: this.options.text,
optionsHtml: this.optionsHtml,
decoratedOptions: this.decoratedOptions,
currentImage: this.currentImage,
buttonClass: this.buttonClass,
hasVariants: this.hasVariants,
buttonDisabled: !this.buttonEnabled,
selectedVariant: this.selectedVariant,
selectedQuantity: this.selectedQuantity,
buttonText: this.buttonText,
imgStyle: this.imgStyle,
quantityClass: this.quantityClass,
priceClass: this.priceClass,
formattedPrice: this.formattedPrice,
priceAccessibilityLabel: this.priceAccessibilityLabel,
hasCompareAtPrice: this.hasCompareAtPrice,
formattedCompareAtPrice: this.formattedCompareAtPrice,
compareAtPriceAccessibilityLabel: this.compareAtPriceAccessibilityLabel,
showUnitPrice: this.showUnitPrice,
formattedUnitPrice: this.formattedUnitPrice,
formattedUnitPriceBaseUnit: this.formattedUnitPriceBaseUnit,
carouselIndex: 0,
carouselImages: this.carouselImages,
});
}
get carouselImages() {
return this.model.images.map((image) => {
return {
id: image.id,
src: image.src,
carouselSrc: this.props.client.image.helpers.imageForSize(image, {maxWidth: 100, maxHeight: 100}),
isSelected: image.id === this.currentImage.id,
altText: this.imageAltText(image.altText),
};
});
}
get buttonClass() {
const disabledClass = this.buttonEnabled ? '' : this.classes.product.disabled;
const quantityClass = this.options.contents.buttonWithQuantity ? this.classes.product.buttonBesideQty : '';
return `${disabledClass} ${quantityClass}`;
}
get quantityClass() {
return this.options.contents.quantityIncrement || this.options.contents.quantityDecrement ? this.classes.product.quantityWithButtons : '';
}
get buttonText() {
if (this.options.buttonDestination === 'modal') {
return this.options.text.button;
}
if (!this.variantExists) {
return this.options.text.unavailable;
}
if (!this.variantInStock) {
return this.options.text.outOfStock;
}
return this.options.text.button;
}
get buttonEnabled() {
return this.options.buttonDestination === 'modal' || (this.buttonActionAvailable && this.variantExists && this.variantInStock);
}
get variantExists() {
return this.model.variants.some((variant) => {
if (this.selectedVariant) {
return variant.id === this.selectedVariant.id;
} else {
return false;
}
});
}
get variantInStock() {
return this.variantExists && this.selectedVariant.available;
}
get hasVariants() {
return this.model.variants.length > 1;
}
get requiresCart() {
return this.options.buttonDestination === 'cart';
}
get buttonActionAvailable() {
return !this.requiresCart || Boolean(this.cart);
}
get hasQuantity() {
return this.options.contents.quantityInput;
}
get priceClass() {
return this.hasCompareAtPrice ? this.classes.product.loweredPrice : '';
}
get isButton() {
return this.options.isButton && !(this.options.contents.button || this.options.contents.buttonWithQuantity);
}
/**
* get events to be bound to DOM.
* @return {Object}
*/
get DOMEvents() {
return merge({}, {
click: this.closeCartOnBgClick.bind(this),
[`click ${this.selectors.option.select}`]: this.stopPropagation.bind(this),
[`focus ${this.selectors.option.select}`]: this.stopPropagation.bind(this),
[`click ${this.selectors.option.wrapper}`]: this.stopPropagation.bind(this),
[`click ${this.selectors.product.quantityInput}`]: this.stopPropagation.bind(this),
[`click ${this.selectors.product.quantityButton}`]: this.stopPropagation.bind(this),
[`change ${this.selectors.option.select}`]: this.onOptionSelect.bind(this),
[`click ${this.selectors.product.button}`]: this.onButtonClick.bind(this),
[`click ${this.selectors.product.blockButton}`]: this.onButtonClick.bind(this),
[`keyup ${this.selectors.product.blockButton}`]: this.onBlockButtonKeyup.bind(this),
[`click ${this.selectors.product.quantityIncrement}`]: this.onQuantityIncrement.bind(this, 1),
[`click ${this.selectors.product.quantityDecrement}`]: this.onQuantityIncrement.bind(this, -1),
[`blur ${this.selectors.product.quantityInput}`]: this.onQuantityBlur.bind(this),
[`click ${this.selectors.product.carouselItem}`]: this.onCarouselItemClick.bind(this),
[`click ${this.selectors.product.carouselNext}`]: this.onCarouselChange.bind(this, 1),
[`click ${this.selectors.product.carouselPrevious}`]: this.onCarouselChange.bind(this, -1),
}, this.options.DOMEvents);
}
/**
* prevent events from bubbling if entire product is being treated as button.
*/
stopPropagation(evt) {
if (this.isButton) {
evt.stopImmediatePropagation();
}
}
/**
* get HTML for product options selector.
* @return {String} HTML
*/
get optionsHtml() {
if (!this.options.contents.options) {
return '';
}
return this.decoratedOptions.reduce((acc, option) => {
const data = merge(option, this.options.viewData);
data.classes = this.classes;
data.onlyOption = (this.model.options.length === 1);
return acc + this.childTemplate.render({data});
}, '');
}
/**
* get product variants with embedded options
* @return {Array} array of variants
*/
get variantArray() {
delete this.variantArrayMemo;
this.variantArrayMemo = this.model.variants.map((variant) => {
const betterVariant = {
id: variant.id,
available: variant.available,
optionValues: {},
};
variant.optionValues.forEach((optionValue) => {
betterVariant.optionValues[optionValue.name] = optionValue.value;
});
return betterVariant;
});
return this.variantArrayMemo;
}
/**
* determines whether an option can resolve to an available variant given current selections
* @return {Boolean}
*/
optionValueCanBeSelected(selections, name, value) {
const variants = this.variantArray;
const selectableValues = Object.assign({}, selections, {
[name]: value,
});
const satisfactoryVariants = variants.filter((variant) => {
const matchingOptions = Object.keys(selectableValues).filter((key) => {
return variant.optionValues[key] === selectableValues[key];
});
return matchingOptions.length === Object.keys(selectableValues).length;
});
let variantSelectable = false;
variantSelectable = satisfactoryVariants.reduce((variantExists, variant) => {
const variantAvailable = variant.available;
if (!variantExists) {
return variantAvailable;
}
return variantExists;
}, false);
return variantSelectable;
}
/**
* get options for product with selected value.
* @return {Array}
*/
get decoratedOptions() {
return this.model.options.map((option) => {
return {
name: option.name,
values: option.values.map((value) => {
return {
name: value.value,
selected: this.selectedOptions[option.name] === value.value,
};
}),
};
});
}
/**
* get info about product to be sent to tracker
* @return {Object}
*/
get trackingInfo() {
const variant = this.selectedVariant || this.model.variants[0];
const contents = this.options.contents;
const contentString = Object.keys(contents).filter((key) => contents[key]).toString();
return {
id: this.model.id,
name: this.model.title,
variantId: variant.id,
variantName: variant.title,
price: variant.priceV2.amount,
destination: this.options.buttonDestination,
layout: this.options.layout,
contents: contentString,
checkoutPopup: this.config.cart.popup,
sku: null,
};
}
/**
* get info about variant to be sent to tracker
* @return {Object}
*/
get selectedVariantTrackingInfo() {
const variant = this.selectedVariant;
return {
id: variant.id,
name: variant.title,
productId: this.model.id,
productName: this.model.title,
quantity: this.selectedQuantity,
price: variant.priceV2.amount,
sku: null,
};
}
/**
* get info about product to be sent to tracker
* @return {Object}
*/
get productTrackingInfo() {
return {
id: this.model.id,
};
}
/**
* get configuration object for product details modal based on product config and modalProduct config.
* @return {Object} configuration object.
*/
get modalProductConfig() {
let modalProductStyles;
if (this.config.product.styles) {
modalProductStyles = merge({}, Object.keys(this.config.product.styles).reduce((productStyles, selectorKey) => {
productStyles[selectorKey] = whitelistedProperties(this.config.product.styles[selectorKey]);
return productStyles;
}, {}), this.config.modalProduct.styles);
} else {
modalProductStyles = {};
}
return Object.assign({}, this.config.modalProduct, {
styles: modalProductStyles,
});
}
/**
* get params for online store URL.
* @return {Object}
*/
get onlineStoreParams() {
return {
channel: 'buy_button',
referrer: encodeURIComponent(windowUtils.location()),
variant: atob(this.selectedVariant.id).split('/')[4],
};
}
/**
* get query string for online store URL from params
* @return {String}
*/
get onlineStoreQueryString() {
return Object.keys(this.onlineStoreParams).reduce((string, key) => {
return `${string}${key}=${this.onlineStoreParams[key]}&`;
}, '?');
}
/**
* get URL to open online store page for product.
* @return {String}
*/
get onlineStoreURL() {
return `${this.model.onlineStoreUrl}${this.onlineStoreQueryString}`;
}
/**
* open online store in new tab.
*/
openOnlineStore() {
this._userEvent('openOnlineStore');
window.open(this.onlineStoreURL);
}
/**
* initializes component by creating model and rendering view.
* Creates and initalizes cart if necessary.
* @param {Object} [data] - data to initialize model with.
* @return {Promise} promise resolving to instance.
*/
init(data) {
return this.createCart().then((cart) => {
this.cart = cart;
return super.init.call(this, data).then((model) => {
if (model) {
this.view.render();
}
return model;
});
});
}
/**
* creates cart if necessary.
* @return {Promise}
*/
createCart() {
const cartConfig = Object.assign({}, this.globalConfig, {
node: this.globalConfig.cartNode,
options: this.config,
});
return this.props.createCart(cartConfig);
}
/**
* fetches data if necessary.
* Sets default variant for product.
* @param {Object} [data] - data to initialize model with.
*/
setupModel(data) {
return super.setupModel(data).then((model) => {
return this.setDefaultVariant(model);
});
}
/**
* fetch product data from API.
* @return {Promise} promise resolving to model data.
*/
sdkFetch() {
if (this.storefrontId && Array.isArray(this.storefrontId) && this.storefrontId[0]) {
return this.props.client.product.fetch(this.storefrontId[0]);
} else if (this.storefrontId && !Array.isArray(this.storefrontId)) {
return this.props.client.product.fetch(this.storefrontId);
} else if (this.handle) {
return this.props.client.product.fetchByHandle(this.handle).then((product) => product);
}
return Promise.reject(new Error('SDK Fetch Failed'));
}
/**
* call sdkFetch and set selected quantity to 0.
* @throw 'Not Found' if model not returned.
* @return {Promise} promise resolving to model data.
*/
fetchData() {
return this.sdkFetch().then((model) => {
if (model) {
this.storefrontId = model.id;
this.handle = model.handle;
return model;
}
throw new Error('Not Found');
});
}
onButtonClick(evt, target) {
evt.stopPropagation();
if (isFunction(this.options.buttonDestination)) {
this.options.buttonDestination(this);
} else if (this.options.buttonDestination === 'cart') {
this.props.closeModal();
this._userEvent('addVariantToCart');
this.props.tracker.trackMethod(this.cart.addVariantToCart.bind(this), 'Update Cart', this.selectedVariantTrackingInfo)(this.selectedVariant, this.selectedQuantity);
if (this.iframe) {
this.props.setActiveEl(target);
}
} else if (this.options.buttonDestination === 'modal') {
this.props.setActiveEl(target);
this.props.tracker.track('Open modal', this.productTrackingInfo);
this.openModal();
} else if (this.options.buttonDestination === 'onlineStore') {
this.openOnlineStore();
} else {
this._userEvent('openCheckout');
this.props.tracker.track('Direct Checkout', {});
let checkoutWindow;
if (this.config.cart.popup && browserFeatures.windowOpen()) {
const params = (new Checkout(this.config)).params;
checkoutWindow = window.open('', 'checkout', params);
} else {
checkoutWindow = window;
}
const input = {
lineItems: [
{
variantId: this.selectedVariant.id,
quantity: this.selectedQuantity,
},
],
};
this.props.client.checkout.create(input).then((checkout) => {
checkoutWindow.location = checkout.webUrl;
});
}
}
onBlockButtonKeyup(evt, target) {
if (evt.keyCode === ENTER_KEY) {
this.onButtonClick(evt, target);
}
}
onOptionSelect(evt) {
const target = evt.target;
const value = target.options[target.selectedIndex].value;
const name = target.getAttribute('name');
this.updateVariant(name, value);
}
onQuantityBlur(evt, target) {
this.updateQuantity(() => parseInt(target.value, 10));
}
onQuantityIncrement(qty) {
this.updateQuantity((prevQty) => prevQty + qty);
}
closeCartOnBgClick() {
if (this.cart && this.cart.isVisible) {
this.cart.close();
}
}
onCarouselItemClick(evt, target) {
evt.preventDefault();
const selectedImageId = target.getAttribute('data-image-id');
const imageList = this.model.images;
const foundImage = imageList.find((image) => {
return image.id === selectedImageId;
});
if (foundImage) {
this.selectedImage = foundImage;
this.cachedImage = foundImage;
}
this.view.render();
}
nextIndex(currentIndex, offset) {
const nextIndex = currentIndex + offset;
if (nextIndex >= this.model.images.length) {
return 0;
}
if (nextIndex < 0) {
return this.model.images.length - 1;
}
return nextIndex;
}
onCarouselChange(offset) {
const imageList = this.model.images;
const currentImage = imageList.filter((image) => {
return image.id === this.currentImage.id;
})[0];
const currentImageIndex = imageList.indexOf(currentImage);
this.selectedImage = imageList[this.nextIndex(currentImageIndex, offset)];
this.cachedImage = this.selectedImage;
this.view.render();
}
/**
* create modal instance and initialize.
* @return {Promise} promise resolving to modal instance
*/
openModal() {
if (!this.modal) {
const modalConfig = Object.assign({}, this.globalConfig, {
node: this.globalConfig.modalNode,
options: Object.assign({}, this.config, {
product: this.modalProductConfig,
modal: Object.assign({}, this.config.modal, {
googleFonts: this.options.googleFonts,
}),
}),
});
this.modal = this.props.createModal(modalConfig, this.props);
}
this._userEvent('openModal');
return this.modal.init(this.model);
}
/**
* update quantity of selected variant and rerender.
* @param {Function} fn - function which returns new quantity given current quantity.
*/
updateQuantity(fn) {
let quantity = fn(this.selectedQuantity);
if (quantity < 0) {
quantity = 0;
}
this.selectedQuantity = quantity;
this._userEvent('updateQuantity');
this.view.render();
}
/**
* Update variant based on option value.
* @param {String} optionName - name of option being modified.
* @param {String} value - value of selected option.
* @return {Object} updated option object.
*/
updateVariant(optionName, value) {
const updatedOption = this.model.options.find((option) => option.name === optionName);
if (updatedOption) {
this.selectedOptions[updatedOption.name] = value;
this.selectedVariant = this.props.client.product.helpers.variantForOptions(this.model, this.selectedOptions);
}
if (this.variantExists) {
this.cachedImage = this.selectedVariant.image;
if (this.selectedVariant.image) {
this.selectedImage = null;
} else {
this.selectedImage = this.model.images[0]; // get cached image
}
} else {
this.selectedImage = this.model.images.find((image) => {
return image.id === this.cachedImage.id;
});
}
this.view.render();
this._userEvent('updateVariant');
return updatedOption;
}
/**
* set default variant to be selected on initialization.
* @param {Object} model - model to be modified.
*/
setDefaultVariant(model) {
let selectedVariant;
if (this.defaultStorefrontVariantId) {
selectedVariant = model.variants.find((variant) => variant.id === this.defaultStorefrontVariantId);
} else {
this.defaultStorefrontVariantId = model.variants[0].id;
selectedVariant = model.variants[0];
this.selectedImage = model.images[0];
}
if (!selectedVariant) {
selectedVariant = model.variants[0];
}
this.selectedOptions = selectedVariant.selectedOptions.reduce((acc, option) => {
acc[option.name] = option.value;
return acc;
}, {});
this.selectedVariant = selectedVariant;
return model;
}
imageAltText(altText) {
return altText || this.model.title;
}
get priceAccessibilityLabel() {
return this.hasCompareAtPrice ? this.options.text.salePriceAccessibilityLabel : this.options.text.regularPriceAccessibilityLabel;
}
get compareAtPriceAccessibilityLabel() {
return this.hasCompareAtPrice ? this.options.text.regularPriceAccessibilityLabel : '';
}
get hasCompareAtPrice() {
return Boolean(this.selectedVariant && this.selectedVariant.compareAtPriceV2);
}
}