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: } }; }