/** * Block Add to Cart when an out-of-stock variation option is selected. * * Shared runtime guard that reads server-emitted out-of-stock markers * (data-b3-wvs-out-of-stock on options, data-b3-wvs-out-of-stock-values + * data-b3-wvs-stock-blocks-selection on the select) and settings keys * (out_of_stock_disable / out_of_stock_behavior). * * @package B3VariationSwatches */ const VARIATION_FORM_SELECTOR = "form.variations_form"; const VARIATION_INPUT_SELECTOR = 'input[name="variation_id"]'; const VARIATION_SELECT_SELECTOR = 'select[name^="attribute_"]'; // Mirror of the extension add-to-cart button selector list without importing // the extension bundle. const ADD_TO_CART_BUTTON_SELECTOR = '.single_add_to_cart_button, .b3-wvs-atc-button, a.add_to_cart_button, a.ajax_add_to_cart, button[name="add-to-cart"], input[name="add-to-cart"], button[type="submit"], input[type="submit"]'; const JQUERY_FOUND_VARIATION_EVENT = "found_variation.b3wvsvariationbutton"; const JQUERY_RESET_EVENTS = "reset_data.b3wvsvariationbutton hide_variation.b3wvsvariationbutton"; const DATA_ORIGINAL_HREF = "b3WvsOriginalHref"; const DATA_ORIGINAL_TAB_INDEX = "b3WvsOriginalTabIndex"; const hasDatasetValue = (element: HTMLElement, key: string): boolean => Object.prototype.hasOwnProperty.call(element.dataset, key); const disableAnchor = (anchor: HTMLAnchorElement): void => { const href = anchor.getAttribute("href"); if (href !== null && !hasDatasetValue(anchor, DATA_ORIGINAL_HREF)) { anchor.dataset[DATA_ORIGINAL_HREF] = href; } if (!hasDatasetValue(anchor, DATA_ORIGINAL_TAB_INDEX)) { anchor.dataset[DATA_ORIGINAL_TAB_INDEX] = anchor.getAttribute("tabindex") ?? ""; } anchor.removeAttribute("href"); anchor.tabIndex = -1; }; const enableAnchor = (anchor: HTMLAnchorElement): void => { if (hasDatasetValue(anchor, DATA_ORIGINAL_HREF)) { anchor.setAttribute("href", anchor.dataset[DATA_ORIGINAL_HREF] ?? ""); delete anchor.dataset[DATA_ORIGINAL_HREF]; } if (hasDatasetValue(anchor, DATA_ORIGINAL_TAB_INDEX)) { const originalTabIndex = anchor.dataset[DATA_ORIGINAL_TAB_INDEX] || ""; if (originalTabIndex) { anchor.setAttribute("tabindex", originalTabIndex); } else { anchor.removeAttribute("tabindex"); } delete anchor.dataset[DATA_ORIGINAL_TAB_INDEX]; } }; const isVariationSelected = (form: HTMLFormElement): boolean => { const isVariableProduct = form.classList.contains("variations_form"); if (!isVariableProduct) return true; const variationId = form.querySelector( VARIATION_INPUT_SELECTOR, )?.value; return !!variationId && variationId !== "0"; }; const shouldBlockOutOfStockSelections = (): boolean => { const settings = typeof b3WvsData !== "undefined" && b3WvsData?.settings ? b3WvsData.settings : null; const outOfStockDisable = Boolean(settings?.out_of_stock_disable); const outOfStockBehavior = typeof settings?.out_of_stock_behavior === "string" ? settings.out_of_stock_behavior.toLowerCase() : "cross"; return outOfStockDisable || outOfStockBehavior === "hide"; }; const selectBlocksOutOfStockSelection = ( select: HTMLSelectElement, ): boolean => { const datasetValue = select.dataset.b3WvsStockBlocksSelection; if (datasetValue === "1") return true; if (datasetValue === "0") return false; return shouldBlockOutOfStockSelections(); }; const isOutOfStockSelected = (form: HTMLFormElement): boolean => { const selects = form.querySelectorAll( VARIATION_SELECT_SELECTOR, ); for (const select of Array.from(selects)) { if (!selectBlocksOutOfStockSelection(select)) { continue; } const value = select.value; if (!value) continue; const selectedOption = select.selectedOptions?.[0]; if (selectedOption?.dataset?.b3WvsOutOfStock === "1") { return true; } const outOfStockRaw = select.dataset.b3WvsOutOfStockValues; if (outOfStockRaw) { try { const parsed = JSON.parse(outOfStockRaw); if ( Array.isArray(parsed) && parsed.map(String).includes(String(value)) ) { return true; } } catch { const list = outOfStockRaw .split(",") .map((val) => val.trim()) .filter(Boolean); if (list.includes(String(value))) { return true; } } } } return false; }; // Re-enable a button WE disabled (identified by our data attribute). const enableButton = (button: HTMLElement): void => { if (button.getAttribute("data-b3-wvs-variation-disabled") !== "true") return; (button as HTMLButtonElement | HTMLInputElement).disabled = false; if (button instanceof HTMLAnchorElement) { enableAnchor(button); } button.setAttribute("aria-disabled", "false"); button.classList.remove("disabled"); button.removeAttribute("data-b3-wvs-variation-disabled"); }; const disableButton = (button: HTMLElement): void => { (button as HTMLButtonElement | HTMLInputElement).disabled = true; if (button instanceof HTMLAnchorElement) { disableAnchor(button); } button.setAttribute("aria-disabled", "true"); button.classList.add("disabled"); button.setAttribute("data-b3-wvs-variation-disabled", "true"); }; const updateButtonState = (form: HTMLFormElement): void => { const button = form.querySelector(ADD_TO_CART_BUTTON_SELECTOR); if (!button) return; const isSelected = isVariationSelected(form); const wasDisabledByUs = button.getAttribute("data-b3-wvs-variation-disabled") === "true"; const hasOutOfStockSelection = isOutOfStockSelected(form); if (!isSelected || hasOutOfStockSelection) { disableButton(button); return; } // Only re-enable a button WE disabled, and never override WooCommerce's own // "selection needed" disabled state (it manages that class while resolving a // variation / fetching AJAX price data). The authoritative re-enable happens // in the found_variation handler below; this guarded path covers the plain // change event, which can fire mid-resolution. if ( wasDisabledByUs && !button.classList.contains("wc-variation-selection-needed") ) { enableButton(button); } }; // found_variation fires once WooCommerce has fully resolved a variation and // carries its purchasable/stock state. Re-enable directly from that data — WC // clears its own wc-variation-selection-needed class up to ~300ms later, so the // class-guarded path in updateButtonState would otherwise leave the button (that // we disabled) stuck disabled after an in-stock variation resolves on WC 10.8+. const handleFoundVariation = ( form: HTMLFormElement, variation?: unknown, ): void => { const button = form.querySelector(ADD_TO_CART_BUTTON_SELECTOR); if (!button) return; const v = variation && typeof variation === "object" ? (variation as { is_purchasable?: boolean; is_in_stock?: boolean }) : null; const purchasable = Boolean(v && v.is_purchasable && v.is_in_stock); if (!v) { updateButtonState(form); return; } if (purchasable && !isOutOfStockSelected(form)) { enableButton(button); return; } disableButton(button); }; let documentListenersBound = false; let jqueryListenersBound = false; /** * Initialize add-to-cart blocking for out-of-stock variation selections. * * Only takes effect when out_of_stock_disable is on (or behavior === "hide"), * which is what makes a select advertise data-b3-wvs-stock-blocks-selection="1". */ export const initVariationButtonState = (): void => { const forms = document.querySelectorAll( VARIATION_FORM_SELECTOR, ); // WooCommerce sets variation_id via jQuery .val() (a property assignment, not // an attribute mutation), so a MutationObserver on the input's value attribute // would never fire. Variation changes are covered by the global change/click // listeners and the jQuery found_variation/reset_data/hide_variation handlers // below, so only the initial pass is needed here. forms.forEach((form) => { updateButtonState(form); }); if (!documentListenersBound) { documentListenersBound = true; document.addEventListener("change", (e) => { const target = e.target as HTMLElement | null; if (!target) return; const form = target.closest( VARIATION_FORM_SELECTOR, ) as HTMLFormElement | null; if (!form) return; updateButtonState(form); }); document.addEventListener("click", (e) => { const target = e.target as HTMLElement | null; if (!target) return; const resetLink = target.closest(".reset_variations"); if (!resetLink) return; const form = resetLink.closest( VARIATION_FORM_SELECTOR, ) as HTMLFormElement | null; if (!form) return; setTimeout(() => updateButtonState(form), 0); }); } const jq = typeof window !== "undefined" ? window.jQuery : undefined; if (!jqueryListenersBound && typeof jq === "function") { jqueryListenersBound = true; jq(document.body).on( JQUERY_FOUND_VARIATION_EVENT, VARIATION_FORM_SELECTOR, function (this: HTMLFormElement, _event: unknown, variation?: unknown) { handleFoundVariation(this, variation); }, ); jq(document.body).on( JQUERY_RESET_EVENTS, VARIATION_FORM_SELECTOR, function (this: HTMLFormElement) { updateButtonState(this); }, ); } };