import memoize from 'lodash.memoize';
import { debounce } from 'throttle-debounce';
import {
CART_PRODUCT_KEY_HAS_CHANGED,
CART_UPDATED_EVENT,
OPTIN_PRODUCT,
OPTOUT_PRODUCT,
PRODUCT_CHANGE_FREQUENCY,
PRODUCT_CHANGE_PREPAID_SHIPMENTS,
RECEIVE_OFFER,
REQUEST_OFFER,
SETUP_CART,
SETUP_PRODUCT
} from '../core/constants';
import { isShopifyDiscountFunctionInUseSelector, makeSubscribedSelector } from '../core/selectors';
import { getOrCreateHidden, safeProductId } from '../core/utils';
import { getTrackingKey } from './shopifyTrackingMiddleware';
import { ShopifyCart, ShopifyProductEntity } from './types/shopify';
import { SetupProductPayload, SetupCartPayload, OfferElement, State } from '../core/types/reducer';
import { type Store } from 'redux';
const SHOPIFY_ROOT = window.Shopify?.routes?.root || '/';
const CART_PAGE_URL = '/cart';
const CART_JS_URL = `${SHOPIFY_ROOT}cart.js`;
const CART_CHANGE_URL = `${SHOPIFY_ROOT}cart/change.js`;
const CART_UPDATE_URL = `${SHOPIFY_ROOT}cart/update.js`;
const PRODUCTS_URL = `${SHOPIFY_ROOT}products/`;
const OFFER_ATTRIBUTE_NAME = '__ordergroove_offer_id';
/**
* List of section DOM elements to update via section-rendering api https://shopify.dev/api/section-rendering
*/
const DEFAULT_SHOPIFY_CART_AJAX_SECTIONS =
'[id^="shopify-section-"][id$=__cart-items], [id^="shopify-section-"][id$="__cart-footer"],#cart-live-region-text,#cart-icon-bubble';
const makeSyncProductId = offer =>
debounce(100, false, function (form) {
const { id } = Object.fromEntries([...new FormData(form).entries()]);
if (id) {
offer.setAttribute('product', id);
} else {
offer.removeAttribute('product');
}
});
async function getCurrency() {
const windowCurrency = window.Shopify?.currency?.active;
if (windowCurrency) {
return windowCurrency;
}
const cart = await getCart();
return cart.currency;
}
export async function setupPdp(store, offer) {
const handle = guessProductHandle(offer);
if (handle) {
try {
const [product, currency] = await Promise.all([getProduct(handle), getCurrency()]);
const payload: SetupProductPayload = { product, offer, currency: currency };
store.dispatch({ type: SETUP_PRODUCT, payload });
} catch (err) {
console.warn('OG: Unable to fetch product details for PDP', err);
}
}
// try closest form (safer)
let form = offer.closest('form');
// sometimes template is so closest does not work
//
//
//
//
//
//
if (!form) {
let ref = offer.parentElement;
// look up parents element of offer that contains the form. This will fix eventually category offers.
while (ref) {
form = ref.querySelector('form[action$="/cart/add"]');
if (form) break;
if (ref.tagName.toLowerCase() === 'body') break;
ref = ref.parentElement;
}
}
if (form) {
// this code watches the cart form for changes, so that we can update the product ID on the offer when the variant changes
const syncProductId = makeSyncProductId(offer);
// watch the cart form for changes
// note: changes to hidden inputs do not fire change events
form.addEventListener('change', () => syncProductId(form));
// watch for added/removed nodes anywhere in the cart form (including updates to text content)
// also watch for changes to the "value" attribute, which will catch changes to hidden inputs
const mo = new MutationObserver(e => {
// if we're only looking at attribute changes
if (e.every(it => it.type === 'attributes')) {
// sync the product ID only if the "id" input changed - this happens when the product variant changes
// for performance reasons, avoid reacting to every form attribute change
if (e.some(it => (it.target as HTMLInputElement).name === 'id')) {
syncProductId(form);
}
} else {
syncProductId(form);
}
});
mo.observe(form, { subtree: true, childList: true, attributes: true, attributeFilter: ['value'] });
} else {
console.info('no /cart/add form found for og-offer', offer);
}
}
async function getCart(): Promise {
return (await fetch(CART_JS_URL)).json();
}
/**
* Attemps to guess the product handle o
* @returns
*/
export function guessProductHandle(offer): string {
return (
[
// Allow specify data-shopify-product-handle attribute offer level so it will work on category qv
//
() => offer?.dataset.shopifyProductHandle,
() =>
// Use the oembed to get the product handle
(document
.querySelector('[href$=".oembed"]')
?.getAttribute('href')
?.match(/\/([^/]+)\.oembed$/) || [])[1],
() =>
// Use the open graph og:type==product and og:url to get the product handle
((document.querySelector('meta[property="og:type"][content="product"]') &&
document
.querySelector('meta[property="og:url"][content]')
?.getAttribute('content')
?.match(/\/([^/]+)$/)) ||
[])[1],
() =>
// use any json in the markup
[...document.querySelectorAll('[type$=json]')]
.map(it => JSON.parse(it.textContent || '{}'))
.find(it => it.handle && it.price)?.handle
]
// returns the first truthy and prevent call next functions
.reduce((acc, cur) => acc || cur(), '')
);
}
const getProduct = memoize(async function (handle: string): Promise {
return (await fetch(`${PRODUCTS_URL}${handle}.js`)).json();
});
async function setupCart(store, offer) {
const cart = await getCart();
const { items } = cart;
const cartPayload: SetupCartPayload = cart;
store.dispatch({ type: SETUP_CART, payload: cartPayload });
// some minicart templates does not contains line.key but contains line which corresponds to
// the index on the cart items (Vedge)
const productAsCartLine = Number(offer.product.id);
if (productAsCartLine <= items.length) {
offer.setAttribute('product', items[productAsCartLine - 1].key);
}
const products = await Promise.all(Array.from(new Set(items.map(({ handle }) => handle))).map(getProduct));
products.forEach(product => {
const payload: SetupProductPayload = { product, offer, currency: cart.currency };
store.dispatch({ type: SETUP_PRODUCT, payload });
});
}
/**
* Synchronizes the optins/optouts using shopify cart ajax api
*
* @param action
* @param store
*/
export async function synchronizeCartOptin(action: any, store: any) {
const offerElement = action.payload.offer;
const selling_plan = action.payload.frequency || getSubscribedFrequency(action.payload.product.id, store);
const trackingEvent = getTrackingEvent(action);
if (!offerElement?.isCart) {
return;
}
try {
// disable the interactions on the offer since we need to process its side-effects first.
offerElement.style.pointerEvents = 'none';
offerElement.style.opacity = '.7';
const sectionsToUpdate = Array.from(document.querySelectorAll(DEFAULT_SHOPIFY_CART_AJAX_SECTIONS));
const key = action.payload.product.id; // shopify cart.item.key
const cart = await getCart();
const offerIx = cart?.items?.findIndex(it => it.key === key); // cart.items[offerIx];
const item = cart.items[offerIx];
const qty = item.quantity;
const productId = safeProductId(key);
const offerIdAttribute = getOfferIdAttribute(store);
const attributes = {
...Object.fromEntries([trackingEvent]),
...(offerIdAttribute ? { [OFFER_ATTRIBUTE_NAME]: offerIdAttribute } : {})
};
if (Object.keys(attributes).length > 0) {
// update the cart attributes
const updateRes = await fetch(CART_UPDATE_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attributes
})
});
if (updateRes.status !== 200) throw new Error('Cart attributes not updated');
}
const changeRes = await fetch(CART_CHANGE_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: key,
quantity: qty,
properties: item.properties,
selling_plan: selling_plan || null,
sections: sectionsToUpdate.map((el: HTMLElement) => el.id.replace(/^shopify-section-/, ''))
})
});
if (changeRes.status !== 200) throw new Error('Cart not updated');
const newCart: ShopifyCart = await changeRes.json();
// If both carts have same length we can update the item.key
// to the original offer element, at least provide
// some graceful degradations if no sections nor cart page
const newKey =
cart.items.length === newCart.items.length
? newCart.items[offerIx].key
: newCart.items.find(
line =>
line.quantity === qty &&
// @ts-expect-error - existing issue flagged after enabling type checking; ignoring for now
line.product_id === productId &&
((!selling_plan && !line.selling_plan_allocation) ||
line?.selling_plan_allocation.selling_plan.id === selling_plan)
)?.key;
if (newKey) {
store.dispatch({
type: CART_PRODUCT_KEY_HAS_CHANGED,
payload: {
oldCartProductKey: key,
newCartProductKey: newKey
}
});
offerElement.setAttribute('product', newKey);
}
// dispatch SETUP_CART so offer does not flip the state
const newCartPayload: SetupCartPayload = newCart;
store.dispatch({ type: SETUP_CART, payload: newCartPayload });
// Use a custom event to hook custom cart updates.
const cartUpdateEvent = new CustomEvent(CART_UPDATED_EVENT, { bubbles: true, cancelable: true });
offerElement.dispatchEvent(cartUpdateEvent);
// Let client uses preventDefault if they want to skip default logic after event.
if (cartUpdateEvent.defaultPrevented) return;
const sections = newCart.sections;
if (Object.values(sections).length) {
sectionsToUpdate.forEach((sectionElement: HTMLElement) => {
const sectionId = sectionElement.id.replace(/^shopify-section-/, '');
if (!(sectionId in sections)) return;
const sectionRawHtml = sections[sectionId];
const el = new DOMParser()
.parseFromString(sectionRawHtml.toString() || '', 'text/html')
.getElementById(sectionElement.id);
if (el) {
sectionElement.innerHTML = el.innerHTML;
}
});
} else if (window.location.pathname.startsWith(CART_PAGE_URL)) {
// only do if we are on the cart page
window.location.reload();
}
} catch (err) {
console.log('OG Error updating cart', err);
} finally {
offerElement.style.pointerEvents = 'auto';
offerElement.style.opacity = '1';
}
}
/**
* Returns a tracking event adhering to the below format:
*
* og__: ",,,,"
*
* Examples:
*
* - optin_product with selling plan ID 123 and variation ID 456:
* og__165653130: "optin_product,pdp,123,456"
*
* - optin_product with no selling plan and variation ID 456:
* og__165653137: "optin_product,pdp,,456"
*
* - optin_product with selling plan ID 123 and no variation ID:
* og__165653139: "optin_product,pdp,123,"
*
* - optin_product with no selling plan and no variation ID:
* og__165653141: "optin_product,pdp,,"
*
* - optout_product with variation id 456:
* og__165653135: "optout_product,pdp,,456"
*
* - product_change_frequency with selling plan ID 123 and variation ID 456:
* og__165653131: "product_change_frequency,pdp,123,456"
*
* @param action a Redux action
* @return {Array} an array with positional values key, value
*/
export function getTrackingEvent(action): Array {
const product_id = action.payload.product.id;
if (!product_id) return [];
const key = getTrackingKey();
const location = action.payload.offer?.location || '';
const variation = action.payload.offer?.variationId || '';
const value = [product_id, action.type.toLowerCase(), location];
switch (action.type) {
case REQUEST_OFFER:
case OPTOUT_PRODUCT:
value.push(''); // No selling plan should be associated with these actions
value.push(variation);
break;
case OPTIN_PRODUCT:
case PRODUCT_CHANGE_FREQUENCY:
value.push(action.payload.frequency);
value.push(variation);
break;
default:
return []; // we dont track anything else
}
return [key, value.join(',')];
}
export function getSubscribedFrequency(productId, store) {
const subscribedSelector = makeSubscribedSelector({ id: productId });
const optin = subscribedSelector(store.getState());
const sellingPlanId = optin ? optin.frequency : undefined;
return sellingPlanId;
}
function getOfferIdAttribute(store: Store) {
const state = store.getState();
// if the Shopify Discount Function is being used, we need to pass along the offer ID as a cart attribute so the discount is calculated correctly
if (isShopifyDiscountFunctionInUseSelector(state)) {
return state.offerId;
}
return null;
}
/**
* // update if available
*
* @param store
*/
export function synchronizeSellingPlan(store: any, offerElement?: OfferElement) {
if (offerElement?.isCart) return; // hidden inputs are used when product page, not cart.
if (!offerElement?.shouldEnableOffer) return; // do not set a selling plan if we're hiding the offer
[...document.querySelectorAll('form[action$="/cart/add"] [name=id]')].forEach((productIdInput: HTMLInputElement) => {
const productId = productIdInput.value;
const sellingPlanId = getSubscribedFrequency(productId, store);
getOrCreateHidden(productIdInput.form, 'selling_plan', sellingPlanId);
getOrCreateHidden(productIdInput.form, `attributes[og__session]`, store.getState().sessionId);
const offerIdAttribute = getOfferIdAttribute(store);
if (offerIdAttribute) {
getOrCreateHidden(productIdInput.form, `attributes[${OFFER_ATTRIBUTE_NAME}]`, offerIdAttribute);
}
if (offerElement) {
// use this to update the product attributes in future
}
});
}
export default function shopifyMiddleware(store) {
return next => action => {
/**
* This redux middleware will perform Shopify specific side-effects such as change
* the product selling plan when offer is cart
*/
switch (action.type) {
case OPTIN_PRODUCT:
case OPTOUT_PRODUCT:
case PRODUCT_CHANGE_FREQUENCY:
break;
case REQUEST_OFFER:
if (action.payload.offer?.isCart) {
setupCart(store, action.payload.offer);
} else {
setupPdp(store, action.payload.offer);
}
break;
default:
}
next(action);
switch (action.type) {
case OPTIN_PRODUCT:
case OPTOUT_PRODUCT:
case PRODUCT_CHANGE_FREQUENCY:
case PRODUCT_CHANGE_PREPAID_SHIPMENTS:
synchronizeCartOptin(action, store);
// falls through
case REQUEST_OFFER:
case RECEIVE_OFFER:
case SETUP_PRODUCT:
synchronizeSellingPlan(store, action.payload.offer);
break;
default:
}
};
}