UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,960 lines (1,834 loc) 70.2 kB
/** * 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