Skip to content

Instantly share code, notes, and snippets.

@briangonzalezmia
Forked from milesw/rcWidget.js
Created April 15, 2020 20:39

Revisions

  1. @milesw milesw revised this gist Mar 19, 2019. No changes.
  2. @milesw milesw created this gist Mar 19, 2019.
    682 changes: 682 additions & 0 deletions rcWidget.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,682 @@
    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;