Last active
June 26, 2024 17:46
-
-
Save milesw/df49ade4e813e26d61b818d47f6e6706 to your computer and use it in GitHub Desktop.
rcWidget.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'core-js/es6/array'; | |
import 'core-js/es6/object'; | |
import 'core-js/es6/string'; | |
// Global options | |
import defaults from './_config'; | |
// general helper functions | |
import Helper from './_helpers'; | |
// general pricing and currency functions | |
import Pricing from './_pricing'; | |
// throttle and debounce manager | |
import Throttler from './throttler'; | |
class rcWidget { | |
constructor() { | |
this.tooltip = null; | |
this.options = {}; | |
this.products = []; | |
this.throttler = new Throttler(); | |
if (arguments[0] && typeof(arguments[0]) === 'object') { | |
Object.assign(this.options, defaults); | |
Object.assign(this.options, arguments[0]); | |
//this.options = { ...defaults, ...arguments[0]}; | |
} else { | |
Object.assign(this.options, defaults); | |
//this.options = { ...defaults}; | |
} | |
} | |
addProduct(product) { // if uninstalled, but active: Runs | |
let self = this; | |
self.installationCheck(); | |
// Don't include duplicate products, search by product.id | |
if (self.products.some(x => x.id === product.id)) { | |
return; | |
} | |
// Create new copy of rcWidget options then override it with product specific options | |
product.options = Object.assign( | |
Object.assign({}, self.options), | |
product | |
); | |
self.products.push(product); | |
} | |
installationCheck() { | |
// If ReCharge isn't installed, trigger fail safe function | |
if (typeof(ReCharge.is_installed) === 'undefined') { | |
ReCharge.is_installed = null; | |
} else { | |
return; | |
} | |
let installationStatus = document.documentElement.innerHTML.indexOf('recharge.js') > -1; | |
ReCharge.is_installed = installationStatus; | |
if (!installationStatus) { | |
rcWidget._failSafe(); | |
} | |
} | |
run() { | |
let self = this; | |
if (!(self.options.active || self.checkTestMode())) { | |
rcWidget._failSafe(); | |
return; | |
} | |
if (self.checkTestMode()) { | |
alert('ReCharge preview mode.'); | |
} | |
if (self.options.debug || self.checkTestMode()) { | |
self.debugMode(); | |
} | |
self.buildWidget(); | |
self.helpers(); | |
self.showAddToCartButton(); | |
self.showWidget(); | |
} | |
checkTestMode() { | |
return !!Helper.getUrlParameter('recharge'); | |
} | |
debugMode() { | |
console.log(this); | |
} | |
buildWidget() { | |
let self = this; | |
self.products.forEach(product => { | |
if (product.status === 'complete') { return; } else { product.status = 'processing'; } | |
product.forms = rcWidget._checkAndGetProductForms(product); | |
product.elements = []; | |
// Get form elements | |
product.forms.forEach(form => { | |
product.elements.push(rcWidget._getFormElements(product, form)); | |
rcWidget._renameElements(form); | |
}); | |
// Get page elements | |
product.elements.forEach(elements => { | |
elements.productPrice = Pricing.bottomUpPriceSearch(product, elements); | |
elements.variantInputs = rcWidget._getVariantInputs(product, elements); | |
}); | |
// Dump element object if productVariantSelect is missing. Do not run additional code on these items. | |
product.elements = product.elements.filter(elements => elements.productVariantSelect); | |
// Product initialization | |
if (product.elements.length) { | |
self.addListeners(product); | |
// Set duplicate select value | |
if (rcWidget._disabledForDuplicates(product)) { | |
// Not needed if disabling duplicates and is also subscription only | |
rcWidget._updateDuplicateSelect(product); | |
} | |
// Set form attributes | |
rcWidget._updateActiveAttributes(product); | |
// Set default interface | |
if (!product.options.subscription_only) { | |
// Not needed if subscription-only | |
rcWidget._updatePricing(product); | |
rcWidget._updateActiveRadio(product); | |
rcWidget._updateWidgetPricing(product); | |
rcWidget._highlightActivePurchaseType(product); | |
} | |
product.status = 'complete'; | |
} else { | |
product.status = 'failed'; | |
} | |
}) | |
} | |
addListeners(product) { | |
product.elements.forEach((elements, i) => { | |
rcWidget._addPurchaseTypeListeners(product, elements); | |
rcWidget._addIntervalOptionListeners(product, elements); | |
rcWidget._addVariantInputListeners(this.throttler, product, elements); | |
rcWidget._addPricingListeners(product); | |
}); | |
} | |
helpers() { | |
rcWidget._identifyTheme(); | |
rcWidget._disableAjaxCart(this.products); | |
if (!window.ReCharge.products.length) { | |
console.warn('No products found', window.ReCharge); | |
} | |
window.ReCharge.products.forEach(product => { | |
if (!product.forms.length) { | |
console.warn('Product form not found', product); | |
} | |
if (!product.elements.some(elements => elements.productPrice)) { | |
console.warn('Product price not found. If missing, pass the price_selector parameter', product.price_selector); | |
} | |
}) | |
window.addEventListener('pageshow', event => { | |
if (event.persisted || window.performance && window.performance.navigation.type === 2) { | |
window.location.reload(); | |
} | |
}, false); | |
} | |
showAddToCartButton() { | |
let buttons = document.querySelectorAll('form[action^="/cart/add"] [type="submit"]'); | |
Array.from(buttons) | |
.forEach(elem => { | |
elem.style.visibility = 'visible'; | |
}); | |
} | |
showWidget() { | |
/* | |
Show the widget for products | |
- Filter the products array | |
- Check for product elements | |
- Check for rcContainer element | |
- On each rcContainer, embed style="display: block;" | |
*/ | |
let self = this; | |
self.products.filter(product => { | |
product.elements.length && product.elements.filter(elements => { | |
elements.rcContainer.style.display = 'block'; | |
}); | |
}); | |
} | |
static _failSafe() { | |
// Trigger functions necessariy to prevent customer disruption | |
ReCharge.showAddToCartButton(); | |
} | |
static _identifyTheme() { | |
if (window.Shopify) { | |
let shopifyTheme = window.Shopify.theme.name, | |
themeList = [ | |
// Original | |
'Alchemy', 'Atlantic', 'Blockshop', 'Brooklyn', | |
'California', 'Classic', 'Clean', 'Envy', 'Fluid', 'Focal', | |
'Kickstand', 'Launchpad', 'Limitless', 'Minimal', 'Mobilia', 'New Standard', | |
'Pacific', 'Palo Alto', 'Parallax', 'Pop', 'Radiance', 'React', 'Responsive', 'Retina', | |
'Solo', 'Startup', 'Supply', 'Vantage', 'Vintage', | |
// Widget v3 | |
'Boundless', 'Debut', 'District', 'Fashionopolism', 'Grid', 'Icon', 'Jumpstart', | |
'Lookbook', 'Pipeline', 'Providence', 'Simple', 'Symmetry', 'Testament', 'Venture', 'Wonderskin', | |
// Widget v3+ | |
'Cookbook', 'Expression', 'Flex', 'Jitensha', 'Masonry', 'Mr Parker', 'Showtime', | |
'Vanity', 'Palo Alto', 'Vintage', 'Weekend', 'Ms Parker', 'Trademark', 'Kingdom', 'Showcase', | |
'Handy', 'Kagami', 'Avenues', 'Turbo', 'Slate', | |
// Problematic themes | |
'Betty', 'Prestige', 'Loft', | |
]; | |
// Search through themeList for (similair) matching theme name | |
let knownTheme = themeList.find(theme => shopifyTheme.toLowerCase().indexOf(theme.toLowerCase()) > -1); | |
// Set identified theme as found theme name or return shopifyTheme name. | |
shopifyTheme = (knownTheme || shopifyTheme).toLowerCase(); | |
if (shopifyTheme === 'turbo') { | |
console.info('Turbo theme detected. If ReCharge widget fails to load, set Page Transitions to "Sport"'); | |
} | |
if (shopifyTheme === 'weekend') { | |
console.info('Weekened theme detected. "properties[shipping_interval_frequency]", "properties[subscription_id]", "properties[shipping_interval_unit_type]" might be missing from data varaible used in addItem()'); | |
} | |
if (shopifyTheme === 'betty') { | |
console.info('Betty theme detected. Ref Issue #12'); | |
} | |
if (shopifyTheme === 'prestige') { | |
console.info('Prestige theme detected. Ref Issue #18'); | |
} | |
if (shopifyTheme === 'loft') { | |
console.info('Loft theme detected. Ref Issue #19'); | |
} | |
document.body.className += ` rc_theme--${shopifyTheme.replace(/[\W_]/g, '-')}`; | |
} | |
} | |
static _disableAjaxCart(products) { | |
/* | |
Not fully fleshed out. Attempts to disable AJAX for stores using `ShopifyAPI.addItemFromForm` | |
*/ | |
let disableAjax = products.some(prod => prod.disable_ajax); | |
if (disableAjax && ShopifyAPI && ShopifyAPI.addItemFromForm) { | |
ShopifyAPI.addItemFromForm = form => { form.submit(); } | |
} | |
} | |
static _checkAndGetProductForms(product) { | |
try { | |
return rcWidget | |
._getProductForms(product) | |
.filter(elem => elem.querySelector('#rc_subscription_id')); | |
} catch (err) { | |
console.error(`Product (${product.id}) has no product forms.`, err); | |
return []; | |
} | |
} | |
static _getProductForms(product) { | |
let query = product.form_selector || `form[data-productid="${product.id}"]`; | |
return Array.from(document.querySelectorAll(query)); | |
} | |
static _renameElements(form) { | |
Helper.renameForIdPair(form, 'rc_purchase_type_onetime'); | |
Helper.renameForIdPair(form, 'rc_purchase_type_autodeliver'); | |
Helper.renameForIdPair(form, 'rc_shipping_interval_frequency'); | |
} | |
static _getFormElements(product, form) { | |
product.options.purchaseType = (product.options.subscription_only || product.options.select_subscription_first) ? 'autodeliver' : 'onetime'; | |
let elements = { | |
// Purchase types | |
'rcContainer': form.querySelector('#rc_container'), | |
'purchaseTypes': form.querySelectorAll('[name="purchase_type"]'), | |
'radioOnetime': form.querySelector('#rc_purchase_type_onetime'), | |
'radioAutodeliver': form.querySelector('#rc_purchase_type_autodeliver'), | |
// Subscription options | |
'subscriptionId': form.querySelector('#rc_subscription_id'), | |
'subscriptionIntervalType': form.querySelector('#rc_shipping_interval_unit_type'), | |
'shippingIntervalFrequency': form.querySelector('#rc_shipping_interval_frequency'), | |
'intervalOptions': form.querySelector('#rc_autodeliver_options'), | |
// Variant selectors | |
'productVariantSelect': form.querySelector('[name="id"]'), | |
'duplicateVariantSelect': form.querySelector('#rc_duplicate_selector'), | |
// Price elements | |
'onetimePrice': form.querySelector('#rc_price_onetime'), | |
'autodeliverPrice': form.querySelector('#rc_price_autodeliver'), | |
'form': form | |
}; | |
// Set active elements | |
Object.assign(elements, { | |
'activePurchaseType': form.querySelector('input[type="radio"][value="' + product.options.purchaseType + '"]'), | |
'activeProductSelect': (product.options.purchaseType == 'autodeliver') ? elements.duplicateVariantSelect : elements.productVariantSelect, | |
}); | |
Object.keys(elements) | |
.filter(k => !elements[k]) | |
.forEach(k => console.info(`[${product.id}] Missing product element: ${k}`, elements[k])); | |
return elements; | |
} | |
static _disabledForDuplicates(product) { | |
/* | |
Set to false if we're disabling duplicate products | |
- Return `true` if disable_duplicates is not set | |
- Return `true` if disable_duplicates is set and product is subscription_only | |
- Otherwise, disable duplicates and return false to prevent code block | |
*/ | |
if (!ReCharge.options.disable_duplicates) { | |
return true; | |
} | |
if (ReCharge.options.disable_duplicates && !product.options.subscription_only) { | |
return true; | |
} | |
return false; | |
} | |
static _getVariantInputs(product, elements) { | |
/* | |
Find any elements that may change variant ID | |
- Search for either provided `options_selector` or via the list | |
- Filter results, ignoring child items of #rc_container | |
- Return array | |
*/ | |
let query = product.options.options_selector || 'select, input, textarea, button, a, span, div'; | |
return Array.from(elements.form.querySelectorAll(query)) | |
.filter(input => !Helper.findAncestor(input, 'rc_container')); | |
} | |
static _getCheckedInput(inputs) { | |
return inputs.find(elem => elem.checked); | |
} | |
static _updatePurchaseType(product, type) { | |
/* | |
Updates product.options.purchaseType as needed | |
- Return true or false if updated | |
*/ | |
if (product.options.purchaseType != type) { | |
product.options.purchaseType = type; | |
return true | |
} | |
return false; | |
} | |
static _updateActiveRadio(product) { | |
product.elements.forEach((elements, i) => { | |
elements.activePurchaseType.checked = true; | |
}); | |
} | |
static _updateDuplicateSelect(product) { | |
product.elements.forEach((elements, i) => { | |
let variantId = elements.productVariantSelect.value, | |
duplicateId = product.options.variant_to_duplicate[variantId]; | |
elements.duplicateVariantSelect.value = duplicateId; | |
}); | |
} | |
static _updateActiveAttributes(product) { | |
if (product.options.purchaseType === 'autodeliver') { | |
rcWidget._activateAutodeliverAttributes(product); | |
} else { | |
rcWidget._activateOnetimeAttributes(product); | |
} | |
} | |
static _activateAutodeliverAttributes(product) { | |
/* | |
Set Subscription properties and form inputs for Autodeliver | |
- Add values for `name` attributes on Subscription inputs | |
- Update visible and dupliate select with correct name="id" attribute value | |
- Only update select attributes if disable_duplicate isn't set to `true` | |
- Update the activePurchaseType from the element list | |
*/ | |
product.elements.forEach((elements, i) => { | |
elements.shippingIntervalFrequency.setAttribute('name', 'properties[shipping_interval_frequency]'); | |
elements.subscriptionId.setAttribute('name', 'properties[subscription_id]'); | |
elements.subscriptionIntervalType.setAttribute('name', 'properties[shipping_interval_unit_type]'); | |
if (rcWidget._disabledForDuplicates(product)) { | |
// Not needed if we're disabling duplicates | |
elements.productVariantSelect.setAttribute('name', ''); | |
elements.duplicateVariantSelect.setAttribute('name', 'id'); | |
elements.activeProductSelect = elements.duplicateVariantSelect; | |
} | |
elements.activePurchaseType = Array.from(elements.purchaseTypes).find(input => input.value === 'autodeliver'); | |
}); | |
} | |
static _activateOnetimeAttributes(product) { | |
/* | |
Set Subscription properties and form inputs for Onetime | |
- Only proceed if is_subscription_only == true | |
- Remove values for `name` attributes on Subscription inputs | |
- Update visible and dupliate select with correct name="id" attribute value | |
- Update the activePurchaseType from the element list | |
*/ | |
if (product.options.is_subscription_only) { | |
// Not needed if subscription only | |
return; | |
} | |
product.elements.forEach((elements, i) => { | |
elements.shippingIntervalFrequency.setAttribute('name', ''); | |
elements.subscriptionId.setAttribute('name', ''); | |
elements.subscriptionIntervalType.setAttribute('name', ''); | |
elements.productVariantSelect.setAttribute('name', 'id'); | |
elements.duplicateVariantSelect.setAttribute('name', ''); | |
elements.activeProductSelect = elements.productVariantSelect; | |
elements.activePurchaseType = Array.from(elements.purchaseTypes).find(input => input.value === 'onetime'); | |
}); | |
} | |
static _highlightActivePurchaseType(product) { | |
product.elements.forEach(elem => { | |
if (elem.radioAutodeliver && elem.radioOnetime) { | |
let autodeliver = elem.rcContainer.querySelector('.rc_block__type__autodeliver'), | |
onetime = elem.rcContainer.querySelector('.rc_block__type__onetime'); | |
autodeliver.className = autodeliver.className.replace(' rc_block__type--active', ''); | |
onetime.className = onetime.className.replace(' rc_block__type--active', ''); | |
if (product.options.purchaseType === 'autodeliver') { | |
autodeliver.className += ' rc_block__type--active'; | |
} else { | |
onetime.className += ' rc_block__type--active'; | |
} | |
} | |
}); | |
} | |
static _updateWidgetPricing(product, force_update) { | |
/* | |
Updates widget UI pricing labels | |
- Don't update pricing if pricing updates are disabled via update_pricing option | |
- Don't update pricing if purchaseType is Onetime | |
*/ | |
if (product.options.update_pricing) { | |
let force = force_update || false; | |
rcWidget._updateOnetimePrice(product, force); | |
rcWidget._updateAutodeliverPrice(product, force); | |
} | |
} | |
static _updatePricing(product, force_update) { | |
/* | |
Updates primary price element | |
- Don't update pricing if pricing updates are disabled via update_pricing option | |
- Don't update pricing if purchaseType is Onetime | |
- Force price update if force_update is true (set) | |
*/ | |
if (product.options.update_pricing) { | |
let force = force_update || false; | |
if (product.options.purchaseType !== 'onetime' || product.options.purchaseType == 'onetime' && force) { | |
rcWidget._updateProductPrice(product, force); | |
} | |
} | |
} | |
static _updateOnetimePrice(product, force_update) { | |
/* | |
Updates the Onetime price indicator on the widget | |
- Updates each occurance of the OneTime price for the product | |
- Uses the variant ID with the variant_to_price object map to locate price | |
- Runs price (in cents) through getFormattedPrice | |
- Updates the innerHTML of the element | |
- Force price update if the price was initiated by addCurrencyListener | |
*/ | |
let force = force_update || false; | |
product.elements.forEach((elements, i) => { | |
if (elements.onetimePrice) { | |
let variantId = elements.productVariantSelect.value, | |
price = product.options.variant_to_price[variantId]; | |
if (product.options.price_onetime === price && !force) { | |
return; | |
} else { | |
product.options.price_onetime = price; | |
} | |
if (!price) { | |
console.warn(`[${product.id}] Price not found. Check product.options.variant_to_price[${variantId}] map.`, price); | |
} | |
elements.onetimePrice.innerHTML = Pricing.getFormattedPrice(product, price); | |
} | |
}); | |
} | |
static _updateAutodeliverPrice(product, force_update) { | |
/* | |
Updates the Autodeliver price indicator on the widget | |
- Updates each occurance of the Autodeliver price for the product | |
- Uses the variant ID with the duplicate_to_price object map to locate price | |
- Runs price (in cents) through getFormattedPrice | |
- Updates the innerHTML of the element | |
- Force price update if the price was initiated by addCurrencyListener | |
*/ | |
let force = force_update || false; | |
product.elements.forEach((elements, i) => { | |
if (elements.autodeliverPrice) { | |
let variantId = elements.duplicateVariantSelect.value, | |
price = product.options.duplicate_to_price[variantId]; | |
if (product.options.price_autodeliver === price && !force) { | |
return; | |
} else { | |
product.options.price_autodeliver = price; | |
} | |
if (!price) { | |
console.warn(`[${product.id}] Price not found. Check product.duplicate_to_price[${variantId}] map.`, price); | |
} | |
elements.autodeliverPrice.innerHTML = Pricing.getFormattedPrice(product, price); | |
} | |
}); | |
} | |
static _updateProductPrice(product, force_update) { | |
/* | |
Updates the primary product price elements | |
- If active_price_search is enabled, perform price search again | |
- If product price elements were found, iterate over each one | |
- Determine the correct product price (onetime vs autorenew) with getSelectedPrice | |
- Format price | |
- Updates the innerHTML of the element | |
- Force price update if the price was initiated by addCurrencyListener | |
*/ | |
let force = force_update || false; | |
product.elements.forEach((elements, i) => { | |
if (ReCharge.options.active_price_search) { | |
elements.productPrice = Pricing.bottomUpPriceSearch(product, elements); | |
} | |
if (elements.productPrice.length) { | |
let price = Pricing.getSelectedPrice(product, elements); | |
if (product.options.price_product === price && !force) { | |
return; | |
} else { | |
product.options.price_product = price; | |
} | |
let formattedPrice = Pricing.getFormattedPrice(product, price); | |
elements.productPrice.forEach(elem => { | |
elem.innerHTML = formattedPrice; | |
}); | |
} | |
}); | |
} | |
static _addPurchaseTypeListeners(product, elements) { | |
/* | |
Listeners attached to one-time/auto-deliver radio options | |
- Triggers when swiching between purchase types | |
- If purchase type is changed, update product.options.purchaseType | |
- If purchase type is changed, update active attributes | |
- If purchase type is changed, update pricing | |
- If purchase type is changed, update interface elements | |
- Do not update pricing if subscription only (no discount) | |
- Force updatePricing | |
- Products with a discount will need main price updated when switching between purchase types | |
*/ | |
if (product.options.subscription_only) { return; } | |
let purchaseTypes = Array.from(elements.purchaseTypes); | |
purchaseTypes | |
.forEach(elem => { | |
elem.addEventListener('click', ev => { | |
let checkedInput = rcWidget._getCheckedInput(purchaseTypes); | |
// Update interface if needed | |
if (rcWidget._updatePurchaseType(product, checkedInput.value)) { | |
rcWidget._updateActiveAttributes(product); | |
rcWidget._updatePricing(product); | |
rcWidget._updateActiveRadio(product); | |
rcWidget._highlightActivePurchaseType(product); | |
} | |
}); | |
}); | |
} | |
static _addIntervalOptionListeners(product, elements) { | |
/* | |
Listeners attached to interval options and the child select, interval frequency | |
- If interval frequency select is clicked, check the autodelivery radio | |
- If interval frequency select is changed, match the value in all form instances | |
*/ | |
if (elements.intervalOptions) { | |
elements.intervalOptions.addEventListener('click', () => { | |
product.elements.forEach(elements => { | |
elements.radioAutodeliver.click(); | |
}); | |
}); | |
elements.shippingIntervalFrequency.addEventListener('change', (evt) => { | |
product.elements.forEach(elements => { | |
elements.shippingIntervalFrequency.value = evt.target.value; | |
}); | |
}); | |
} | |
} | |
static _addVariantInputListeners(throttler, product, elements) { | |
/* | |
Listeners attached to variant/option selectors | |
- Identify the ideal event listener per element | |
- Attach functioners to identified event | |
- If an option is changed, update the dupcate select with the correct variant | |
- If an option is changed, update pricing (we need to check if price changed before trying to change it) | |
*/ | |
elements.variantInputs.forEach((elem) => { | |
let listenerAction = Helper.getListenerAction(elem); | |
elem.addEventListener(listenerAction, ev => { | |
throttler.throttleAndDebounce(ev, ev => { | |
if (rcWidget._disabledForDuplicates(product)) { | |
rcWidget._updateDuplicateSelect(product); | |
} | |
if (!product.options.subscription_only) { | |
rcWidget._updatePricing(product, true); // Not sure we need to force this anymore | |
rcWidget._updateWidgetPricing(product); | |
} | |
}, product.options.delay_listener); | |
}) | |
}); | |
} | |
static _addPricingListeners(product) { | |
/* | |
Add listeners to any supported Currency Switchers | |
- First check if product is subscription_only | |
- Query required Currency Triggers | |
- Identify valid Currency object | |
- If objects found check if product | |
- Trigger `_updatePricing` and `_updateWidgetPricing` if valid | |
*/ | |
if (product.options.subscription_only) { return; } | |
Pricing.addCurrencyListener(elem => { | |
let currencyConvertObj = window.Currency ? window.Currency : window.DoublyGlobalCurrency; | |
currencyConvertObj.currentCurrency = elem.target.value; | |
rcWidget._updatePricing(product, true); | |
rcWidget._updateWidgetPricing(product, true); | |
}); | |
} | |
} | |
export default rcWidget; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment