@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,960 lines (1,834 loc) • 70.2 kB
JavaScript
/**
* Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact Volker Schukai.
*
* SPDX-License-Identifier: AGPL-3.0
*/
import { instanceSymbol } from "../../constants.mjs";
import { addAttributeToken } from "../../dom/attributes.mjs";
import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs";
import { CustomControl } from "../../dom/customcontrol.mjs";
import {
assembleMethodSymbol,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { getDocument } from "../../dom/util.mjs";
import { Pathfinder } from "../../data/pathfinder.mjs";
import { Formatter } from "../../text/formatter.mjs";
import { isFunction, isObject, isString } from "../../types/is.mjs";
import { CommonStyleSheet } from "../stylesheet/common.mjs";
import { FormStyleSheet } from "../stylesheet/form.mjs";
import { VariantSelectStyleSheet } from "./stylesheet/variant-select.mjs";
import { BuyBoxStyleSheet } from "./stylesheet/buy-box.mjs";
import "./variant-select.mjs";
import "./quantity.mjs";
import "./message-state-button.mjs";
import "../datatable/datasource/dom.mjs";
import "../datatable/datasource/rest.mjs";
export { BuyBox };
/**
* @private
* @type {symbol}
*/
const controlElementSymbol = Symbol("controlElement");
/**
* @private
* @type {symbol}
*/
const variantElementSymbol = Symbol("variantElement");
/**
* @private
* @type {symbol}
*/
const quantityElementSymbol = Symbol("quantityElement");
/**
* @private
* @type {symbol}
*/
const quantityInputElementSymbol = Symbol("quantityInputElement");
/**
* @private
* @type {symbol}
*/
const quantityUnitElementSymbol = Symbol("quantityUnitElement");
/**
* @private
* @type {symbol}
*/
const submitElementSymbol = Symbol("submitElement");
/**
* @private
* @type {symbol}
*/
const submitLabelElementSymbol = Symbol("submitLabelElement");
/**
* @private
* @type {symbol}
*/
const messageElementSymbol = Symbol("messageElement");
/**
* @private
* @type {symbol}
*/
const priceElementSymbol = Symbol("priceElement");
/**
* @private
* @type {symbol}
*/
const sumElementSymbol = Symbol("sumElement");
/**
* @private
* @type {symbol}
*/
const listPriceElementSymbol = Symbol("listPriceElement");
/**
* @private
* @type {symbol}
*/
const basePriceElementSymbol = Symbol("basePriceElement");
/**
* @private
* @type {symbol}
*/
const taxElementSymbol = Symbol("taxElement");
/**
* @private
* @type {symbol}
*/
const totalElementSymbol = Symbol("totalElement");
/**
* @private
* @type {symbol}
*/
const deliveryElementSymbol = Symbol("deliveryElement");
/**
* @private
* @type {symbol}
*/
const quantityStaticElementSymbol = Symbol("quantityStaticElement");
/**
* @private
* @type {symbol}
*/
const datasourceElementSymbol = Symbol("datasourceElement");
/**
* @private
* @type {symbol}
*/
const productDataSymbol = Symbol("productData");
/**
* @private
* @type {symbol}
*/
const cartDataSymbol = Symbol("cartData");
/**
* @private
* @type {symbol}
*/
const stockRequestSymbol = Symbol("stockRequest");
/**
* @private
* @type {symbol}
*/
const cartControlSymbol = Symbol("cartControl");
/**
* @private
* @type {symbol}
*/
const pendingCartPayloadSymbol = Symbol("pendingCartPayload");
/**
* @private
* @type {symbol}
*/
const pricingRequestSymbol = Symbol("pricingRequest");
/**
* @private
* @type {symbol}
*/
const pricingCacheSymbol = Symbol("pricingCache");
/**
* BuyBox
*
* @fragments /fragments/components/form/buy-box/
*
* @example /examples/components/form/buy-box-basic Basic buy box
*
* @summary Product buy box control with variants, quantity, pricing, and add-to-cart.
* @since 4.65.0
* @fires monster-buy-box-change
* @fires monster-buy-box-valid
* @fires monster-buy-box-invalid
* @fires monster-buy-box-submit
* @fires monster-buy-box-success
* @fires monster-buy-box-error
* @fires monster-buy-box-loaded
*/
class BuyBox extends CustomControl {
/**
* This method is called by the `instanceof` operator.
* @return {symbol}
*/
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/components/form/buy-box@@instance");
}
/**
* To set the options via the HTML tag, the attribute `data-monster-options` must be used.
* @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
*
* @property {Object} templates Template definitions
* @property {string} templates.main Main template
* @property {Object} labels Labels
* @property {Object} features Feature toggles
* @property {boolean} features.messages Show status messages
* @property {boolean} features.rowSelects Allow row selects in variant select
* @property {boolean} features.combinedSelect Allow combined variant select
* @property {boolean} features.allowDecimal Allow decimal quantities
* @property {Object} product Static product config
* @property {Object} pricing Static pricing config
* @property {Object} variants Variant configuration (passed to monster-variant-select)
* @property {Object} quantity Quantity configuration
* @property {Object} cart Cart configuration (endpoint + mapping or cart control selector)
* @property {Object} stock Stock configuration (endpoint + mapping)
* @property {Object} datasource Datasource config (selector or rest)
* @property {Object} mapping Mapping from datasource data to product values
* @property {Object} actions Callback actions
*/
get defaults() {
return Object.assign({}, super.defaults, {
templates: {
main: getTemplate(),
},
labels: getTranslations(),
features: {
messages: true,
rowSelects: false,
combinedSelect: false,
allowDecimal: false,
},
product: {
sku: null,
name: null,
stock: null,
delivery: null,
unitLabel: null,
currency: "EUR",
taxIncluded: true,
taxRate: null,
},
pricing: {
price: null,
listPrice: null,
basePrice: null,
basePriceUnit: null,
tiers: [],
api: {
url: null,
features: {
alwaysFetch: false,
},
fetch: {
method: "GET",
headers: {
accept: "application/json",
},
},
mapping: {
selector: "*",
priceTemplate: null,
listPriceTemplate: null,
basePriceTemplate: null,
basePriceUnitTemplate: null,
tiersPath: null,
taxRateTemplate: null,
taxIncludedTemplate: null,
},
},
},
variants: {
dimensions: [],
data: [],
features: {},
pricing: {
priceKey: "price",
listPriceKey: "listPrice",
basePriceKey: "basePrice",
basePriceUnitKey: "basePriceUnit",
currencyKey: "currency",
taxRateKey: "taxRate",
taxIncludedKey: "taxIncluded",
tiersKey: "tiers",
},
},
quantity: {
value: 1,
min: 1,
max: Number.POSITIVE_INFINITY,
step: 1,
precision: null,
allowed: null,
rounding: "half-up",
},
datasource: {
selector: null,
rest: null,
},
mapping: {
selector: "*",
skuTemplate: null,
nameTemplate: null,
stockTemplate: null,
deliveryTemplate: null,
unitLabelTemplate: null,
priceTemplate: null,
listPriceTemplate: null,
basePriceTemplate: null,
basePriceUnitTemplate: null,
currencyTemplate: null,
taxIncludedTemplate: null,
taxRateTemplate: null,
variantsPath: null,
cartPath: null,
},
stock: {
url: null,
fetch: {
method: "GET",
headers: {
accept: "application/json",
},
},
mapping: {
selector: "*",
stockTemplate: null,
},
features: {
autoCheck: true,
},
},
cart: {
url: null,
method: "POST",
headers: {
"Content-Type": "application/json",
},
controlSelector: null,
bodyTemplate: null,
mapping: {
selector: "*",
skuTemplate: null,
qtyTemplate: null,
},
},
actions: {
onchange: null,
onvalid: null,
oninvalid: null,
onsubmit: null,
onsuccess: null,
onerror: null,
onload: null,
},
});
}
/**
* @return {BuyBox}
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
this[pricingCacheSymbol] = new Map();
initControlReferences.call(this);
initDatasource.call(this);
initEventHandler.call(this);
updateSubmitLabel.call(this);
applyData.call(this);
return this;
}
/**
* Re-apply options and mapped data.
* @return {BuyBox}
*/
refresh() {
initDatasource.call(this);
applyData.call(this);
return this;
}
/**
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [
CommonStyleSheet,
FormStyleSheet,
VariantSelectStyleSheet,
BuyBoxStyleSheet,
];
}
/**
* @return {string}
*/
static getTag() {
return "monster-buy-box";
}
}
/**
* @private
*/
function initControlReferences() {
this[controlElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=control]",
);
this[variantElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=variants]",
);
this[quantityElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=quantity]",
);
this[quantityInputElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=quantity-input]",
);
this[quantityUnitElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=quantity-unit]",
);
this[submitElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=submit]",
);
this[submitLabelElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=submit-label]",
);
this[messageElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=message]",
);
this[quantityStaticElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=quantity-static]",
);
this[priceElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=price]",
);
this[sumElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=sum]",
);
this[listPriceElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=list-price]",
);
this[basePriceElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=base-price]",
);
this[taxElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=tax]",
);
this[totalElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=total]",
);
this[deliveryElementSymbol] = this.shadowRoot.querySelector(
"[data-monster-role=delivery]",
);
}
/**
* @private
* @return {HTMLElement|null}
*/
function getCartControl() {
const selector = this.getOption("cart.controlSelector");
if (!isString(selector) || selector === "") {
return null;
}
if (this[cartControlSymbol] && this[cartControlSymbol].isConnected) {
return this[cartControlSymbol];
}
const element = getDocument().querySelector(selector);
if (element) {
this[cartControlSymbol] = element;
}
return element || null;
}
/**
* @private
*/
function initDatasource() {
if (this[datasourceElementSymbol]) {
return;
}
const selector = this.getOption("datasource.selector");
if (isString(selector) && selector !== "") {
this[datasourceElementSymbol] = getDocument().querySelector(selector);
} else if (isObject(this.getOption("datasource.rest"))) {
const rest = getDocument().createElement("monster-datasource-rest");
rest.setOption("read", this.getOption("datasource.rest.read", {}));
rest.setOption("features.autoInit", true);
rest.setOption("autoInit.oneTime", true);
this.appendChild(rest);
this[datasourceElementSymbol] = rest;
}
if (this[datasourceElementSymbol]) {
this[datasourceElementSymbol].addEventListener(
"monster-datasource-fetched",
(event) => {
const data = event?.detail?.data || this[datasourceElementSymbol]?.data;
if (data) {
this[productDataSymbol] = data;
applyData.call(this);
}
},
);
}
}
/**
* @private
*/
function initEventHandler() {
const variants = this.shadowRoot.querySelector("monster-variant-select");
const quantity = this.shadowRoot.querySelector("monster-quantity");
const submit = this[submitElementSymbol];
if (variants) {
variants.addEventListener("monster-variant-select-change", () => {
updateState.call(this);
checkStockIfNeeded.call(this);
checkPricingIfNeeded.call(this);
});
}
if (quantity) {
quantity.addEventListener("monster-quantity-change", () => {
updateState.call(this);
checkStockIfNeeded.call(this);
checkPricingIfNeeded.call(this);
});
}
if (this[quantityInputElementSymbol]) {
this[quantityInputElementSymbol].addEventListener("input", () => {
updateState.call(this);
checkStockIfNeeded.call(this);
checkPricingIfNeeded.call(this);
});
}
if (submit) {
submit.setOption("actions.click", () => {
handleSubmit.call(this);
});
}
}
/**
* @private
*/
function applyData() {
const data = this[productDataSymbol] || this.getOption("product");
if (!data) {
return;
}
const mapped = mapProductData.call(this, data);
this[productDataSymbol] = mapped;
this[cartDataSymbol] = mapped.cart;
applyVariants.call(this, mapped);
applyQuantity.call(this);
updateState.call(this, true);
checkStockIfNeeded.call(this);
checkPricingIfNeeded.call(this);
updateSubmitLabel.call(this);
fireCustomEvent(this, "monster-buy-box-loaded", { product: mapped });
const loadAction = this.getOption("actions.onload");
if (isFunction(loadAction)) {
loadAction.call(this, mapped);
}
}
/**
* @private
* @param {Object} data
* @return {Object}
*/
function mapProductData(data) {
const mapping = this.getOption("mapping", {});
let source = data;
if (
isString(mapping?.selector) &&
mapping.selector !== "*" &&
mapping.selector
) {
try {
source = new Pathfinder(data).getVia(mapping.selector);
} catch (_e) {
source = data;
}
}
const value = (key) => mapValueFromSource(source, mapping[key]);
const product = Object.assign({}, this.getOption("product"), {
sku: value("skuTemplate") ?? this.getOption("product.sku"),
name: value("nameTemplate") ?? this.getOption("product.name"),
stock: parseNumber(value("stockTemplate"), this.getOption("product.stock")),
delivery: value("deliveryTemplate") ?? this.getOption("product.delivery"),
unitLabel:
value("unitLabelTemplate") ?? this.getOption("product.unitLabel"),
currency: value("currencyTemplate") ?? this.getOption("product.currency"),
taxIncluded: parseBoolean(
value("taxIncludedTemplate"),
this.getOption("product.taxIncluded"),
),
taxRate: parseNumber(
value("taxRateTemplate"),
this.getOption("product.taxRate"),
),
});
const pricing = Object.assign({}, this.getOption("pricing"), {
price: parseNumber(value("priceTemplate"), this.getOption("pricing.price")),
listPrice: parseNumber(
value("listPriceTemplate"),
this.getOption("pricing.listPrice"),
),
basePrice: parseNumber(
value("basePriceTemplate"),
this.getOption("pricing.basePrice"),
),
basePriceUnit:
value("basePriceUnitTemplate") ?? this.getOption("pricing.basePriceUnit"),
});
const variantsPath = mapping?.variantsPath;
const variantData = variantsPath
? new Pathfinder(source).getVia(variantsPath)
: this.getOption("variants.data");
const cartPath = mapping?.cartPath;
const cartData = cartPath ? new Pathfinder(source).getVia(cartPath) : null;
return { product, pricing, variants: variantData, cart: cartData, source };
}
/**
* @private
* @param {Object} source
* @param {string|null} template
* @return {string|null}
*/
function mapValueFromSource(source, template) {
if (!isString(template) || template === "") {
return null;
}
try {
if (template.includes("${")) {
return new Formatter(source).format(template);
}
return new Pathfinder(source).getVia(template);
} catch (_e) {
return null;
}
}
/**
* @private
* @param {Object} mapped
*/
function applyVariants(mapped) {
const variants = this.shadowRoot.querySelector("monster-variant-select");
if (!variants) {
return;
}
variants.setAttribute(
"aria-label",
this.getOption("labels.variantsAria") || "Variant selection",
);
const variantOptions = Object.assign({}, this.getOption("variants"));
if (Array.isArray(mapped?.variants)) {
variantOptions.data = mapped.variants;
}
variantOptions.features = Object.assign({}, variantOptions.features, {
rowSelects: this.getOption("features.rowSelects"),
combinedSelect: this.getOption("features.combinedSelect"),
});
const hasDimensions =
Array.isArray(variantOptions.dimensions) &&
variantOptions.dimensions.length > 0;
const hasData =
Array.isArray(variantOptions.data) && variantOptions.data.length > 0;
const hasUrl = isString(variantOptions.url) && variantOptions.url !== "";
if (this[variantElementSymbol]) {
this[variantElementSymbol].hidden = !(hasDimensions && (hasData || hasUrl));
}
variants.setOption("dimensions", variantOptions.dimensions || []);
variants.setOption("data", variantOptions.data || []);
variants.setOption("url", variantOptions.url ?? null);
variants.setOption("fetch", variantOptions.fetch || {});
variants.setOption("mapping", variantOptions.mapping || {});
variants.setOption("layout", variantOptions.layout || {});
variants.setOption("features.rowSelects", variantOptions.features.rowSelects);
variants.setOption(
"features.combinedSelect",
variantOptions.features.combinedSelect,
);
if (typeof variants.refresh === "function") {
variants.refresh();
}
}
/**
* @private
*/
function applyQuantity() {
const quantity = this.shadowRoot.querySelector("monster-quantity");
const quantityInput = this[quantityInputElementSymbol];
const quantityStatic = this[quantityStaticElementSymbol];
const quantityLabel = this.shadowRoot.querySelector(
"[data-monster-role=quantity-label]",
);
if (!quantity) {
return;
}
if (!quantityInput) {
return;
}
quantity.setAttribute(
"aria-label",
this.getOption("labels.quantityAria") || "Quantity",
);
quantityInput.setAttribute(
"aria-label",
this.getOption("labels.quantityAria") || "Quantity",
);
const options = this.getOption("quantity", {});
const precision =
this.getOption("features.allowDecimal") === true
? Number(options.precision ?? 2)
: 0;
const useDecimalInput = this.getOption("features.allowDecimal") === true;
const hasFixedQuantity = isFixedQuantity(options);
quantity.setOption("min", options.min);
quantity.setOption("max", options.max);
quantity.setOption("step", options.step);
quantity.setOption("precision", precision);
quantity.setOption("inputmode", useDecimalInput ? "decimal" : "numeric");
quantity.value = options.value ?? options.min ?? 1;
quantity.hidden = useDecimalInput || hasFixedQuantity;
quantityInput.hidden = !useDecimalInput || hasFixedQuantity;
quantityInput.step = options.step ?? 1;
quantityInput.min = Number.isFinite(options.min) ? options.min : "";
quantityInput.max = Number.isFinite(options.max) ? options.max : "";
quantityInput.value = formatQuantityForLocale(
Number.isFinite(options.value) ? options.value : (options.min ?? 1),
precision,
);
if (quantityStatic) {
quantityStatic.hidden = !hasFixedQuantity;
if (hasFixedQuantity) {
const label = this.getOption("labels.quantity") || "Quantity";
setElementText(
quantityStatic,
`${label}: ${formatQuantityForLocale(options.min ?? 1, precision)}`,
);
}
}
if (quantityLabel) {
quantityLabel.hidden = hasFixedQuantity;
}
if (this[quantityUnitElementSymbol]) {
const unit = this[productDataSymbol]?.product?.unitLabel;
this[quantityUnitElementSymbol].textContent =
unit && unit !== "" ? `(${unit})` : "";
}
}
/**
* @private
* @param {boolean} initial
*/
function updateState(initial = false) {
const variants = this.shadowRoot.querySelector("monster-variant-select");
const quantity = this.shadowRoot.querySelector("monster-quantity");
const selection = variants?.getOption?.("selection") || {};
const hasVariants = hasVariantOptions.call(this, variants);
const sku = hasVariants ? variants?.value : this.getOption("product.sku");
const qty = getQuantityValue.call(this, quantity);
const productBase =
this[productDataSymbol]?.product || this.getOption("product");
const pricingBase =
this[productDataSymbol]?.pricing || this.getOption("pricing");
const variantData = findSelectedVariantData.call(this, variants, selection);
const variantPricingActive = hasVariantPricing.call(this, variants);
const resolved = resolvePricing.call(
this,
pricingBase,
productBase,
variantData,
qty,
variantPricingActive,
);
const product = resolved.product;
const pricing = resolved.pricing;
const isPriceOk = resolved.priceAvailable !== false;
const stock = product?.stock;
const cartItem = findCartItem.call(this, sku);
const hasSku = isString(sku) && sku !== "";
const isValidQty = isQuantityAllowed.call(this, qty);
const isStockOk = hasSku
? Number.isFinite(stock)
? qty <= stock
: true
: true;
const isVariantOk = hasSku;
updatePriceDisplay.call(this, pricing, qty, product);
updateMessage.call(this, {
isValidQty,
isStockOk,
isVariantOk,
isPriceOk,
hasVariants,
});
const detail = {
sku,
selection,
qty,
product,
pricing,
cartItem,
priceAvailable: isPriceOk,
};
fireCustomEvent(this, "monster-buy-box-change", detail);
const changeAction = this.getOption("actions.onchange");
if (isFunction(changeAction)) {
changeAction.call(this, detail);
}
const valid = isValidQty && isStockOk && isVariantOk && isPriceOk;
if (!initial) {
if (valid) {
fireCustomEvent(this, "monster-buy-box-valid", detail);
const action = this.getOption("actions.onvalid");
if (isFunction(action)) {
action.call(this, detail);
}
} else {
fireCustomEvent(this, "monster-buy-box-invalid", detail);
const action = this.getOption("actions.oninvalid");
if (isFunction(action)) {
action.call(this, detail);
}
}
}
setSubmitEnabled.call(this, valid);
}
/**
* @private
* @param {Object} pricing
* @param {number} qty
* @param {Object} product
*/
function updatePriceDisplay(pricing, qty, product) {
const currency = product?.currency || "EUR";
const price = pricing?.price;
const listPrice = pricing?.listPrice;
const basePrice = pricing?.basePrice;
const baseUnit = pricing?.basePriceUnit;
const taxIncluded = product?.taxIncluded;
const taxRate = product?.taxRate;
const subtotal =
price !== null && price !== undefined && Number.isFinite(qty)
? price * qty
: null;
if (this[priceElementSymbol]) {
setElementText(
this[priceElementSymbol],
price !== null && price !== undefined ? formatMoney(price, currency) : "",
);
}
if (this[sumElementSymbol]) {
setElementText(
this[sumElementSymbol],
subtotal !== null ? formatMoney(subtotal, currency) : "",
);
}
if (this[listPriceElementSymbol]) {
setElementText(
this[listPriceElementSymbol],
listPrice !== null && listPrice !== undefined
? formatMoney(listPrice, currency)
: "",
);
}
if (this[basePriceElementSymbol]) {
if (basePrice !== null && basePrice !== undefined) {
const base = formatMoney(basePrice, currency);
setElementText(
this[basePriceElementSymbol],
baseUnit ? `${base} / ${baseUnit}` : base,
);
} else {
setElementText(this[basePriceElementSymbol], "");
}
}
if (this[taxElementSymbol]) {
const label = taxIncluded
? this.getOption("labels.taxIncluded")
: this.getOption("labels.taxExcluded");
if (Number.isFinite(taxRate)) {
setElementText(this[taxElementSymbol], `${label} (${taxRate}%)`);
} else {
setElementText(this[taxElementSymbol], label);
}
}
if (this[totalElementSymbol]) {
const total = subtotal;
setElementText(
this[totalElementSymbol],
total !== null ? formatMoney(total, currency) : "",
);
}
if (this[deliveryElementSymbol]) {
setElementText(
this[deliveryElementSymbol],
product?.delivery ?? this.getOption("labels.deliveryUnknown"),
);
}
}
/**
* @private
* @param {Object} state
*/
function updateMessage(state) {
if (this.getOption("features.messages") === false) {
if (this[messageElementSymbol]) {
this[messageElementSymbol].textContent = "";
}
return;
}
if (!this[messageElementSymbol]) {
return;
}
let msg = "";
msg = getInvalidMessage.call(this, state);
this[messageElementSymbol].textContent = msg;
}
/**
* @private
* @param {Object} state
* @return {string}
*/
function getInvalidMessage(state) {
if (!state) {
return "";
}
if (!state.isVariantOk && state.hasVariants) {
return this.getOption("labels.selectVariant");
}
if (state.isPriceOk === false) {
return this.getOption("labels.priceUnavailable");
}
if (!state.isValidQty) {
return this.getOption("labels.selectQuantity");
}
if (!state.isStockOk) {
return this.getOption("labels.outOfStock");
}
return "";
}
/**
* @private
* @param {boolean} enabled
*/
function setSubmitEnabled(enabled) {
if (!this[submitElementSymbol]) {
return;
}
this[submitElementSymbol].setOption("features.disableButton", false);
}
/**
* @private
*/
function handleSubmit() {
const variants = this.shadowRoot.querySelector("monster-variant-select");
const quantity = this.shadowRoot.querySelector("monster-quantity");
const hasVariants = hasVariantOptions.call(this, variants);
const sku = hasVariants ? variants?.value : this.getOption("product.sku");
const qty = getQuantityValue.call(this, quantity);
const selection = variants?.getOption?.("selection") || {};
const productBase =
this[productDataSymbol]?.product || this.getOption("product");
const pricingBase =
this[productDataSymbol]?.pricing || this.getOption("pricing");
const variantData = findSelectedVariantData.call(this, variants, selection);
const variantPricingActive = hasVariantPricing.call(this, variants);
const resolved = resolvePricing.call(
this,
pricingBase,
productBase,
variantData,
qty,
variantPricingActive,
);
const product = resolved.product;
const pricing = resolved.pricing;
const isPriceOk = resolved.priceAvailable !== false;
const hasSku = isString(sku) && sku !== "";
const stock = product?.stock;
const isValidQty = isQuantityAllowed.call(this, qty);
const isStockOk = hasSku
? Number.isFinite(stock)
? qty <= stock
: true
: true;
const isVariantOk = hasSku;
const valid = isValidQty && isStockOk && isVariantOk && isPriceOk;
if (!valid) {
updateState.call(this);
const button = this[submitElementSymbol];
const message = getInvalidMessage.call(this, {
isValidQty,
isStockOk,
isVariantOk,
isPriceOk,
hasVariants,
});
button?.setState("failed", 3000);
button?.setMessage(message);
button?.showMessage?.(3000);
return;
}
const detail = { sku, qty, selection, product, pricing };
fireCustomEvent(this, "monster-buy-box-submit", detail);
const action = this.getOption("actions.onsubmit");
if (isFunction(action)) {
action.call(this, detail);
}
const button = this[submitElementSymbol];
const cartControl = getCartControl.call(this);
if (cartControl && isFunction(cartControl.addOrChange)) {
this[pendingCartPayloadSymbol] = detail;
button?.setState("activity");
const onVerified = (event) => {
if (!matchesCartPayload.call(this, event?.detail)) {
return;
}
this[pendingCartPayloadSymbol] = null;
cartControl.removeEventListener(
"monster-cart-control-verified",
onVerified,
);
cartControl.removeEventListener("monster-cart-control-error", onError);
const responseData = event?.detail?.data;
const items = Array.isArray(responseData)
? responseData
: Array.isArray(responseData?.items)
? responseData.items
: null;
if (items) {
this[cartDataSymbol] = items;
updateState.call(this);
}
button?.setState("successful", 2000);
fireCustomEvent(this, "monster-buy-box-success", {
sku,
qty,
payload: event?.detail?.data,
});
const okAction = this.getOption("actions.onsuccess");
if (isFunction(okAction)) {
okAction.call(this, { sku, qty, payload: event?.detail?.data });
}
};
const onError = (event) => {
if (!matchesCartPayload.call(this, event?.detail)) {
return;
}
this[pendingCartPayloadSymbol] = null;
cartControl.removeEventListener(
"monster-cart-control-verified",
onVerified,
);
cartControl.removeEventListener("monster-cart-control-error", onError);
button?.setState("failed", 3000);
button?.setMessage(this.getOption("labels.addToCartError"));
button?.showMessage?.(3000);
fireCustomEvent(this, "monster-buy-box-error", {
sku,
qty,
error: event?.detail?.error,
});
const errAction = this.getOption("actions.onerror");
if (isFunction(errAction)) {
errAction.call(this, event?.detail?.error);
}
};
cartControl.addEventListener("monster-cart-control-verified", onVerified);
cartControl.addEventListener("monster-cart-control-error", onError);
cartControl.addOrChange(detail);
return;
}
const url = this.getOption("cart.url");
if (!isString(url) || url === "") {
const notConfigured = this.getOption("labels.addToCartNotConfigured");
button?.setState("failed", 3000);
button?.setMessage(notConfigured);
button?.showMessage?.(3000);
fireCustomEvent(this, "monster-buy-box-error", {
sku,
qty,
error: new Error("cart url not configured"),
});
const errAction = this.getOption("actions.onerror");
if (isFunction(errAction)) {
errAction.call(this, new Error("cart url not configured"));
}
return;
}
button?.setState("activity");
fetch(url, buildCartRequest.call(this, detail))
.then(async (response) => {
if (!response.ok) {
throw new Error("add to cart failed");
}
const payload = await response.json();
button?.setState("successful", 2000);
fireCustomEvent(this, "monster-buy-box-success", { sku, qty, payload });
const okAction = this.getOption("actions.onsuccess");
if (isFunction(okAction)) {
okAction.call(this, { sku, qty, payload });
}
})
.catch((e) => {
button?.setState("failed", 3000);
button?.setMessage(this.getOption("labels.addToCartError"));
button?.showMessage?.(3000);
fireCustomEvent(this, "monster-buy-box-error", { sku, qty, error: e });
const errAction = this.getOption("actions.onerror");
if (isFunction(errAction)) {
errAction.call(this, e);
}
});
}
/**
* @private
* @param {Object} detail
* @return {boolean}
*/
function matchesCartPayload(detail) {
const pending = this[pendingCartPayloadSymbol];
if (!pending) {
return false;
}
const source = detail?.source || detail?.payload;
if (!source || typeof source !== "object") {
return true;
}
if (isString(source.sku) && source.sku !== pending.sku) {
return false;
}
if (source.qty !== undefined && Number(source.qty) !== Number(pending.qty)) {
return false;
}
return true;
}
/**
* @private
* @param {Object} data
* @return {Object}
*/
function buildCartRequest(data) {
const headers = Object.assign({}, this.getOption("cart.headers", {}));
const method = this.getOption("cart.method") || "POST";
const bodyTemplate = this.getOption("cart.bodyTemplate");
let body = null;
if (isString(bodyTemplate) && bodyTemplate.includes("${")) {
body = new Formatter(stringifyForTemplate(data)).format(bodyTemplate);
} else {
body = JSON.stringify(data);
}
return { method, headers, body };
}
/**
* @private
* @param {Object|Array|string|number|boolean|null} value
* @return {Object|Array|string}
*/
function stringifyForTemplate(value) {
if (value === null || value === undefined) {
return "";
}
if (Array.isArray(value)) {
return value.map((entry) => stringifyForTemplate(entry));
}
if (typeof value === "object") {
const result = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = stringifyForTemplate(entry);
}
return result;
}
return `${value}`;
}
/**
* @private
* @param {number} value
* @return {boolean}
*/
function isQuantityAllowed(value) {
if (!Number.isFinite(value)) {
return false;
}
const options = this.getOption("quantity", {});
if (this.getOption("features.allowDecimal") !== true) {
if (!Number.isInteger(value)) {
return false;
}
}
const min = Number(options.min);
const max = Number(options.max);
if (Number.isFinite(min) && value < min) {
return false;
}
if (Number.isFinite(max) && value > max) {
return false;
}
const allowed = options.allowed;
if (!allowed) {
return true;
}
const { list, ranges } = parseAllowed(allowed);
if (list.length === 0 && ranges.length === 0) {
return true;
}
const allowDecimal = this.getOption("features.allowDecimal") === true;
const precision = Number.isFinite(options.precision)
? options.precision
: allowDecimal
? 2
: 0;
const rounded = roundQuantity(value, precision, options.rounding);
if (list.includes(rounded)) {
return true;
}
for (const range of ranges) {
if (rounded >= range.min && rounded <= range.max) {
return true;
}
}
return false;
}
/**
* @private
* @param {string|Array} allowed
* @return {Object}
*/
function parseAllowed(allowed) {
if (Array.isArray(allowed)) {
return {
list: allowed.filter((v) => Number.isFinite(v)),
ranges: [],
};
}
if (!isString(allowed)) {
return { list: [], ranges: [] };
}
const list = [];
const ranges = [];
const parts = allowed.split(",");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") {
continue;
}
if (trimmed.includes("-")) {
const [minRaw, maxRaw] = trimmed.split("-");
const min = minRaw === "" ? Number.NEGATIVE_INFINITY : Number(minRaw);
const max = maxRaw === "" ? Number.POSITIVE_INFINITY : Number(maxRaw);
if (Number.isFinite(min) || Number.isFinite(max)) {
ranges.push({ min, max });
}
continue;
}
const value = Number(trimmed);
if (Number.isFinite(value)) {
list.push(value);
}
}
return { list, ranges };
}
/**
* @private
* @param {HTMLElement|null} variants
* @param {Object} selection
* @return {Object|null}
*/
function findSelectedVariantData(variants, selection) {
if (!(variants instanceof HTMLElement)) {
return null;
}
const data = variants.getOption?.("data") || [];
const dimensions = variants.getOption?.("dimensions") || [];
if (!Array.isArray(data) || data.length === 0) {
return null;
}
if (!selection || Object.keys(selection).length === 0) {
return null;
}
for (const item of data) {
let matches = true;
for (const def of dimensions) {
const key = def?.key;
if (!isString(key) || key === "") {
continue;
}
const expected = selection?.[key];
if (!isString(expected) || expected === "") {
matches = false;
break;
}
const value = getDimensionValueFromItem(item, def);
if (String(value) !== String(expected)) {
matches = false;
break;
}
}
if (matches) {
return item;
}
}
return null;
}
/**
* @private
* @param {HTMLElement|null} variants
* @return {boolean}
*/
function hasVariantPricing(variants) {
if (!(variants instanceof HTMLElement)) {
return false;
}
const data = variants.getOption?.("data") || [];
if (!Array.isArray(data) || data.length === 0) {
return false;
}
const pricingKeys = getVariantPricingKeys.call(this);
const priceKey = pricingKeys.priceKey;
const tiersKey = pricingKeys.tiersKey;
for (const item of data) {
if (isString(priceKey) && priceKey !== "") {
const value = item?.[priceKey];
if (value !== null && value !== undefined && value !== "") {
return true;
}
}
if (isString(tiersKey) && tiersKey !== "") {
const tiers = item?.[tiersKey];
if (Array.isArray(tiers) && tiers.length > 0) {
return true;
}
}
}
return false;
}
/**
* @private
* @param {Object} item
* @param {Object} def
* @return {string|null}
*/
function getDimensionValueFromItem(item, def) {
const template = def?.valueTemplate;
if (isString(template) && template !== "") {
return new Formatter(item).format(template);
}
const key = def?.key;
if (!isString(key) || key === "") {
return null;
}
try {
return new Pathfinder(item).getVia(key);
} catch (_e) {
return item?.[key];
}
}
/**
* @private
* @param {Object} pricing
* @param {Object} product
* @param {Object|null} variantData
* @param {number} qty
* @return {{pricing: Object, product: Object}}
*/
function resolvePricing(
pricing,
product,
variantData,
qty,
variantPricingActive = false,
) {
let nextPricing = Object.assign({}, pricing);
let nextProduct = Object.assign({}, product);
let priceAvailable = true;
if (variantPricingActive) {
nextPricing = Object.assign({}, nextPricing, {
price: null,
listPrice: null,
basePrice: null,
basePriceUnit: null,
tiers: [],
});
if (!variantData) {
priceAvailable = false;
}
}
if (variantData) {
const pricingKeys = getVariantPricingKeys.call(this);
const priceKey = pricingKeys.priceKey;
const listPriceKey = pricingKeys.listPriceKey;
const basePriceKey = pricingKeys.basePriceKey;
const basePriceUnitKey = pricingKeys.basePriceUnitKey;
const currencyKey = pricingKeys.currencyKey;
const taxRateKey = pricingKeys.taxRateKey;
const taxIncludedKey = pricingKeys.taxIncludedKey;
const tiersKey = pricingKeys.tiersKey;
const variantPrice =
isString(priceKey) && priceKey !== "" ? variantData?.[priceKey] : null;
const variantListPrice =
isString(listPriceKey) && listPriceKey !== ""
? variantData?.[listPriceKey]
: null;
const variantBasePrice =
isString(basePriceKey) && basePriceKey !== ""
? variantData?.[basePriceKey]
: null;
const variantBaseUnit =
isString(basePriceUnitKey) && basePriceUnitKey !== ""
? variantData?.[basePriceUnitKey]
: null;
const variantCurrency =
isString(currencyKey) && currencyKey !== ""
? variantData?.[currencyKey]
: null;
const variantTaxRate =
isString(taxRateKey) && taxRateKey !== ""
? variantData?.[taxRateKey]
: null;
const variantTaxIncluded =
isString(taxIncludedKey) && taxIncludedKey !== ""
? variantData?.[taxIncludedKey]
: null;
const variantTiers =
isString(tiersKey) && tiersKey !== "" ? variantData?.[tiersKey] : null;
if (variantPrice !== null && variantPrice !== undefined) {
nextPricing.price = parseNumber(variantPrice, nextPricing.price);
}
if (variantListPrice !== null && variantListPrice !== undefined) {
nextPricing.listPrice = parseNumber(
variantListPrice,
nextPricing.listPrice,
);
}
if (variantBasePrice !== null && variantBasePrice !== undefined) {
nextPricing.basePrice = parseNumber(
variantBasePrice,
nextPricing.basePrice,
);
}
if (variantBaseUnit !== null && variantBaseUnit !== undefined) {
nextPricing.basePriceUnit = variantBaseUnit;
}
if (variantCurrency !== null && variantCurrency !== undefined) {
nextProduct.currency = variantCurrency;
}
if (variantTaxRate !== null && variantTaxRate !== undefined) {
nextProduct.taxRate = parseNumber(variantTaxRate, nextProduct.taxRate);
}
if (variantTaxIncluded !== null && variantTaxIncluded !== undefined) {
nextProduct.taxIncluded = parseBoolean(
variantTaxIncluded,
nextProduct.taxIncluded,
);
}
if (Array.isArray(variantTiers)) {
nextPricing.tiers = variantTiers;
}
}
const tiers = Array.isArray(nextPricing.tiers)
? nextPricing.tiers
: Array.isArray(this.getOption("pricing.tiers"))
? this.getOption("pricing.tiers")
: [];
if (tiers.length > 0 && Number.isFinite(qty)) {
const tier = findTierForQuantity(tiers, qty);
if (tier) {
const tierResult = applyTier(tier, nextPricing);
nextPricing = Object.assign({}, nextPricing, tierResult.pricing);
if (tierResult.currency) {
nextProduct.currency = tierResult.currency;
}
}
}
if (variantPricingActive) {
priceAvailable = Number.isFinite(nextPricing.price);
}
return { pricing: nextPricing, product: nextProduct, priceAvailable };
}
/**
* @private
* @return {Object}
*/
function getVariantPricingKeys() {
const defaults = {
priceKey: "price",
listPriceKey: "listPrice",
basePriceKey: "basePrice",
basePriceUnitKey: "basePriceUnit",
currencyKey: "currency",
taxRateKey: "taxRate",
taxIncludedKey: "taxIncluded",
tiersKey: "tiers",
};
const configured = this.getOption("variants.pricing");
if (!isObject(configured)) {
return defaults;
}
return Object.assign({}, defaults, configured);
}
/**
* @private
* @param {Array} tiers
* @param {number} qty
* @return {Object|null}
*/
function findTierForQuantity(tiers, qty) {
let match = null;
for (const tier of tiers) {
const minRaw = tier?.min ?? tier?.from ?? tier?.qty ?? null;
const maxRaw = tier?.max ?? tier?.to ?? null;
const min = minRaw === null ? 1 : Number(minRaw);
const max = maxRaw === null || maxRaw === undefined ? null : Number(maxRaw);
if (!Number.isFinite(min)) {
continue;
}
if (qty < min) {
continue;
}
if (max !== null && Number.isFinite(max) && qty > max) {
continue;
}
if (!match || min > Number(match.min ?? 0)) {
match = Object.assign({}, tier, { min, max });
}
}
return match;
}
/**
* @private
* @param {Object} tier
* @param {Object} fallback
* @return {Object}
*/
function applyTier(tier, fallback) {
const next = Object.assign({}, fallback);
let currency = null;
if (tier.price !== undefined) {
next.price = parseNumber(tier.price, next.price);
}
if (tier.listPrice !== undefined) {
next.listPrice = parseNumber(tier.listPrice, next.listPrice);
}
if (tier.basePrice !== undefined) {
next.basePrice = parseNumber(tier.basePrice, next.basePrice);
}
if (tier.basePriceUnit !== undefined) {
next.basePriceUnit = tier.basePriceUnit;
}
if (tier.currency !== undefined) {
currency = tier.currency;
}
return { pricing: next, currency };
}
/**
* @private
* @param {string|null} sku
* @return {string}
*/
function getPricingCacheKey(sku) {
if (isString(sku) && sku !== "") {
return sku;
}
return "__base__";
}
/**
* @private
* @param {{product: Object, pricing: Object}|null} cached
*/
function applyPricingCache(cached) {
if (!cached) {
return;
}
this[productDataSymbol] = Object.assign({}, this[productDataSymbol], {
product: cached.product,
pricing: cached.pricing,
});
}
/**
* @private
* @param {Object} options
* @return {boolean}
*/
function isFixedQuantity(options) {
const min = Number(options.min);
const max = Number(options.max);
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return false;
}
return min === 1 && max === 1;
}
/**
* @private
* @param {HTMLElement|null} variants
* @return {boolean}
*/
function hasVariantOptions(variants) {
if (!(variants instanceof HTMLElement)) {
return false;
}
const data = variants.getOption?.("data") || [];
const url = variants.getOption?.("url");
return (
(Array.isArray(data) && data.length > 0) || (isString(url) && url !== "")
);
}
/**
* @private
* @param {HTMLElement|null} quantityControl
* @return {number|null}
*/
function getQuantityValue(quantityControl) {
const useDecimalInput = this.getOption("features.allowDecimal") === true;
if (useDecimalInput) {
const raw = this[quantityInputElementSymbol]?.value ?? "";
const parsed = parseLocaleNumber(raw);
if (Number.isFinite(parsed)) {
return parsed;
}
}
const fallback =
this.getOption("quantity.value") ?? this.getOption("quantity.min") ?? 1;
if (useDecimalInput) {
return Number(fallback);
}
const value = quantityControl?.value;
if (Number.isFinite(value)) {
return value;
}
return Number(fallback);
}
/**
* @private
* @param {number} value
* @param {number|null} precision
* @param {string} rounding
* @return {number}
*/
function roundQuantity(value, precision, rounding) {
if (!Number.isFinite(value)) {
return value;
}
if (!Number.isFinite(precision)) {
return value;
}
const factor = 10 ** precision;
const scaled = value * factor;
if (rounding === "half-up") {
return Math.round(scaled) / factor;
}
if (rounding === "floor") {
return Math.floor(scaled) / factor;
}
if (rounding === "ceil") {
return Math.ceil(scaled) / factor;
}
return Math.round(scaled) / factor;
}
/**
* @private
* @param {string} value
* @param {string} currency
* @return {string}
*/
function formatMoney(value, currency) {
try {
const locale = getLocaleOfDocument();
return new Intl.NumberFormat(locale.locale, {
style: "currency",
currency,
}).format(value);
} catch (_e) {
return `${value} ${currency}`;
}
}
/**
* @private
* @param {number} value
* @param {number} precision
* @return {string}
*/
function formatQuantityForLocale(value, precision) {
try {
const locale = getLocaleOfDocument();
return new Intl.NumberFormat(locale.locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
}).format(value);
} catch (_e) {
return `${value}`;
}
}
/**
* @private
* @param {string} value
* @return {number}
*/
function parseLocaleNumber(value) {
if (!isString(value)) {
return Number.NaN;
}
const trimmed = value.trim();
if (trimmed === "") {
return Number.NaN;
}
const locale = getLocaleOfDocument();
const decimal = getDecimalSeparator(locale.locale);
const group = decimal === "," ? "." : ",";
const normalized = trimmed
.replace(new RegExp(`\\${group}`, "g"), "")
.replace(new RegExp(`\\${decimal}`), ".");
return Number(normalized);
}
/**
* @private
* @param {string} locale
* @return {string}
*/
function getDecimalSeparator(locale) {
try {
const parts = new Intl.NumberFormat(locale).formatToParts(1.1);
const separator = parts.find((part) => part.type === "decimal");
return separator?.value || ".";
} catch (_e) {
return ".";
}
}
/**
* @private
*/
function updateSubmitLabel() {
if (!this[submitLabelElementSymbol]) {
return;
}
const label = this.getOption("labels.addToCart") || "Add to cart";
this[submitLabelElementSymbol].textContent = label;
}
/**
* @private
* @param {HTMLElement} element
* @param {string} text
*/
function setElementText(element, text) {
element.textContent = text;
element.hidden = text === "";
}
/**
* @private
* @param {string|null} sku
* @return {Object|null}
*/
function findCartItem(sku) {
if (!isString(sku) || sku === "") {
return null;
}
const cart = this[cartDataSymbol];
if (!Array.isArray(cart)) {
return null;
}
const skuTemplate = this.getOption("cart.mapping.skuTemplate");
const qtyTemplate = this.getOption("cart.mapping.qtyTemplate");
for (const item of cart) {
const itemSku =
mapValueFromSource(item, skuTemplate) ?? item?.sku ?? item?.id ?? null;
if (itemSku === sku) {
const qty = parseNumber(
mapValueFromSource(item, qtyTemplate),
item?.qty ?? item?.quantity ?? null,
);
return { sku: itemSku, qty, source: item };
}
}
return null;
}
/**
* @private
*/
function checkStockIfNeeded() {
const options = this.getOption("stock", {});
const url = options?.url;
if (!isString(url) || url === "") {
return;
}
if (options?.features?.autoCheck === false) {
return;
}
if (this[stockRequestSymbol]) {
this[stockRequestSymbol].abort?.();
this[stockRequestSymbol] = null;
}
const controller = new AbortController();
this[stockRequestSymbol] = controller;
const sku = this.shadowRoot.querySelector("monster-variant-select")?.value;
if (!isString(sku) || sku === "") {
return;
}
const query = sku ? `?sku=${encodeURIComponent(sku)}` : "";
fetch(url + query, {
...options.fetch,
signal: controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error("stock check failed");
}
return response.json();
})
.then((json) => {
const selector = options?.mapping?.selector || "*";
let data = json;
if (selector !== "*" && selector !== "") {
data = new Pathfinder(json).getVia(selector);
}
const stock = mapValueFromSource(data, options?.mapping?.stockTemplate);
const product = this[productDataSymbol]?.product || {};
this[productDataSymbol] = Object.assign({}, this[productDataSymbol], {
product: Object.assign({}, product, {
stock: parseNumber(stock, product.stock),
}),
});
updateState.call(this);
})
.catch((e) => {
if (e.name === "AbortError") {
return;
}
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
});
}
/**
* @private
*/
function checkPricingIfNeeded() {
const api = this.getOption("pricing.api", {});
const url = api?.url;
if (!isString(url) || url === "") {
return;
}
if (this[pricingRequestSymbol]) {
this[pricingRequestSymbol].abort?.();
this[pricingRequestSymbol] = null;
}
const variants = this.shadowRoot.querySelector("monster-variant-select");
const quantity = this.shadowRoot.querySelector("monster-quantity");
const hasVariants = hasVariantOptions.call(this, variants);
const sku = hasVariants ? variants?.value : this.getOption("product.sku");
const qty = getQuantityValue.call(this, quantity);
const selection = variants?.getOption?.("selection") || {};
if (hasVariants && (!isString(sku) || sku === "")) {
return;
}
const alwaysFetch = api?.features?.alwaysFetch === true;
const cacheKey = getPricingCacheKey.call(this, sku);
if (!alwaysFetch && cacheKey && this[pricingCacheSymbol]?.has(cacheKey)) {
const cached = this[pricingCacheSymbol].get(cacheKey);
applyPricingCache.call(this, cached