UNPKG

@schukai/monster

Version:

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

711 lines (670 loc) 18 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 { 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 { CartControlStyleSheet } from "./stylesheet/cart-control.mjs"; import "../datatable/datasource/rest.mjs"; export { CartControl }; /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * @private * @type {symbol} */ const countElementSymbol = Symbol("countElement"); /** * @private * @type {symbol} */ const totalElementSymbol = Symbol("totalElement"); /** * @private * @type {symbol} */ const statusElementSymbol = Symbol("statusElement"); /** * @private * @type {symbol} */ const datasourceElementSymbol = Symbol("datasourceElement"); /** * @private * @type {symbol} */ const cartDataSymbol = Symbol("cartData"); /** * @private * @type {symbol} */ const pendingPayloadSymbol = Symbol("pendingPayload"); /** * @private * @type {symbol} */ const pendingRequestSymbol = Symbol("pendingRequest"); /** * CartControl * * @summary Cart control that syncs with a datasource and exposes pending vs verified state. * @fires monster-cart-control-pending * @fires monster-cart-control-verified * @fires monster-cart-control-error * @fires monster-cart-control-update */ class CartControl extends CustomControl { static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/form/cart-control@@instance", ); } /** * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} labels Labels * @property {Object} datasource Datasource configuration * @property {string} datasource.selector Selector for datasource * @property {Object} datasource.rest Rest datasource config * @property {Object} cart Cart write configuration * @property {Object} mapping Response mapping * @property {Object} state Control state * @property {Object} actions Callback actions */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: getTranslations(), datasource: { selector: null, rest: null, }, cart: { url: null, method: "POST", headers: { "Content-Type": "application/json", }, bodyTemplate: null, }, mapping: { selector: "*", itemsPath: "items", totalTemplate: null, currencyTemplate: null, qtyTemplate: "qty", }, state: { status: "idle", }, actions: { onpending: null, onverified: null, onerror: null, onupdate: null, }, }); } [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initDatasource.call(this); applyData.call(this); setStatus.call(this, this.getOption("state.status") || "idle"); return this; } /** * Re-apply mapped data from options or datasource. * @return {CartControl} */ refresh() { applyData.call(this); return this; } static getTag() { return "monster-cart-control"; } static getCSSStyleSheet() { return [CommonStyleSheet, FormStyleSheet, CartControlStyleSheet]; } /** * Add or change an item in the cart. * @param {Object} payload */ addOrChange(payload) { const request = buildPayload.call(this, payload); this[pendingPayloadSymbol] = payload; this[pendingRequestSymbol] = request; setStatus.call(this, "pending"); fireCustomEvent(this, "monster-cart-control-pending", { payload: request, source: payload, }); const action = this.getOption("actions.onpending"); if (isFunction(action)) { action.call(this, { payload: request, source: payload }); } if (!this[datasourceElementSymbol]) { initDatasource.call(this); } if (!this[datasourceElementSymbol]) { handleError.call(this, new Error("cart datasource not configured")); return; } this[datasourceElementSymbol].data = request; this[datasourceElementSymbol].write().catch((e) => { handleError.call(this, e); }); } } /** * @private */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=control]", ); this[countElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=count]", ); this[totalElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=total]", ); this[statusElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=status]", ); } /** * @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("write", this.getOption("datasource.rest.write", {})); rest.setOption("features.autoInit", false); this.appendChild(rest); this[datasourceElementSymbol] = rest; } else if (isString(this.getOption("cart.url"))) { const rest = getDocument().createElement("monster-datasource-rest"); rest.setOption("write.url", this.getOption("cart.url")); rest.setOption("write.method", this.getOption("cart.method")); rest.setOption("write.init", { method: this.getOption("cart.method"), headers: this.getOption("cart.headers"), }); rest.setOption("features.autoInit", false); this.appendChild(rest); this[datasourceElementSymbol] = rest; } if (this[datasourceElementSymbol]) { this[datasourceElementSymbol].setOption( "write.responseCallback", (payload) => { this[datasourceElementSymbol].data = payload; }, ); this[datasourceElementSymbol].addEventListener( "monster-datasource-fetched", (event) => { const data = event?.detail?.data || this[datasourceElementSymbol]?.data; if (data) { this[cartDataSymbol] = data; applyData.call(this); } setStatus.call(this, "verified"); fireCustomEvent(this, "monster-cart-control-verified", { data, payload: this[pendingRequestSymbol], source: this[pendingPayloadSymbol], }); const action = this.getOption("actions.onverified"); if (isFunction(action)) { action.call(this, { data, payload: this[pendingRequestSymbol], source: this[pendingPayloadSymbol], }); } this[pendingPayloadSymbol] = null; this[pendingRequestSymbol] = null; }, ); this[datasourceElementSymbol].addEventListener( "monster-datasource-error", (event) => { handleError.call(this, event?.detail?.error); }, ); } } /** * @private */ function applyData() { const data = this[cartDataSymbol] || this.getOption("data"); if (!data) { return; } 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 itemsPath = mapping.itemsPath; const items = itemsPath ? new Pathfinder(source).getVia(itemsPath) : source?.items; const count = Array.isArray(items) ? items.reduce((sum, item) => { const qty = readNumber(item, mapping.qtyTemplate) ?? 0; return sum + qty; }, 0) : 0; const total = readNumber(source, mapping.totalTemplate); const currency = readString(source, mapping.currencyTemplate) || "EUR"; if (this[countElementSymbol]) { setElementText( this[countElementSymbol], formatCount(count, this.getOption("labels.items")), ); } if (this[totalElementSymbol]) { setElementText( this[totalElementSymbol], total !== null ? formatMoney(total, currency) : "", ); } fireCustomEvent(this, "monster-cart-control-update", { count, total, currency, items, }); const action = this.getOption("actions.onupdate"); if (isFunction(action)) { action.call(this, { count, total, currency, items }); } } /** * @private * @param {string} status */ function setStatus(status) { this.setOption("state.status", status); if (!this[statusElementSymbol]) { return; } let label = ""; if (status === "pending") { label = this.getOption("labels.statusPending"); } else if (status === "verified") { label = this.getOption("labels.statusVerified"); } else if (status === "error") { label = this.getOption("labels.statusError"); } else { label = this.getOption("labels.statusIdle"); } setElementText(this[statusElementSymbol], label); } /** * @private * @param {Error} error */ function handleError(error) { setStatus.call(this, "error"); fireCustomEvent(this, "monster-cart-control-error", { error, payload: this[pendingRequestSymbol], source: this[pendingPayloadSymbol], }); const action = this.getOption("actions.onerror"); if (isFunction(action)) { action.call(this, { error, payload: this[pendingRequestSymbol], source: this[pendingPayloadSymbol], }); } this[pendingPayloadSymbol] = null; this[pendingRequestSymbol] = null; } /** * @private * @param {Object} payload * @return {Object} */ function buildPayload(payload) { if (!payload) { return {}; } const data = Object.assign({}, payload); const template = this.getOption("cart.bodyTemplate"); if (isString(template) && template.includes("${")) { const formatted = new Formatter(stringifyForTemplate(data)).format( template, ); try { return JSON.parse(formatted); } catch (_e) { return { value: formatted }; } } return data; } /** * @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 {Object} source * @param {string|null} template * @return {string|null} */ function readString(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} source * @param {string|null} template * @return {number|null} */ function readNumber(source, template) { const value = readString(source, template); if (value === null || value === undefined) { return null; } const num = Number(value); return Number.isFinite(num) ? num : null; } /** * @private * @param {number} value * @param {string} label * @return {string} */ function formatCount(value, label) { const safeValue = Number.isFinite(value) ? value : 0; return `${safeValue} ${label}`; } /** * @private * @param {number} 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 {HTMLElement} element * @param {string} text */ function setElementText(element, text) { element.textContent = text; element.hidden = text === ""; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <div data-monster-role="summary" part="summary"> <div data-monster-role="count" part="count"></div> <div data-monster-role="total" part="total"></div> </div> <div data-monster-role="status" part="status"></div> </div> `; } /** * @private * @returns {object} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { items: "Artikel", statusIdle: "Warenkorb bereit", statusPending: "Warenkorb wird aktualisiert", statusVerified: "Warenkorb bestaetigt", statusError: "Warenkorb Fehler", }; case "es": return { items: "Articulos", statusIdle: "Carrito listo", statusPending: "Carrito actualizando", statusVerified: "Carrito verificado", statusError: "Error del carrito", }; case "zh": return { items: "商品", statusIdle: "购物车已就绪", statusPending: "购物车更新中", statusVerified: "购物车已确认", statusError: "购物车错误", }; case "hi": return { items: "आइटम", statusIdle: "कार्ट तैयार", statusPending: "कार्ट अपडेट हो रहा है", statusVerified: "कार्ट सत्यापित", statusError: "कार्ट त्रुटि", }; case "bn": return { items: "আইটেম", statusIdle: "কার্ট প্রস্তুত", statusPending: "কার্ট আপডেট হচ্ছে", statusVerified: "কার্ট নিশ্চিত", statusError: "কার্ট ত্রুটি", }; case "pt": return { items: "Itens", statusIdle: "Carrinho pronto", statusPending: "Carrinho atualizando", statusVerified: "Carrinho verificado", statusError: "Erro no carrinho", }; case "ru": return { items: "Tovary", statusIdle: "Korzina gotova", statusPending: "Korzina obnovlyaetsya", statusVerified: "Korzina podtverzhdena", statusError: "Oshibka korziny", }; case "ja": return { items: "商品", statusIdle: "カート準備完了", statusPending: "カート更新中", statusVerified: "カート確認済み", statusError: "カートエラー", }; case "pa": return { items: "ਆਈਟਮ", statusIdle: "ਕਾਰਟ ਤਿਆਰ", statusPending: "ਕਾਰਟ ਅੱਪਡੇਟ ਹੋ ਰਿਹਾ ਹੈ", statusVerified: "ਕਾਰਟ ਪੁਸ਼ਟੀਤ", statusError: "ਕਾਰਟ ਗਲਤੀ", }; case "mr": return { items: "आयटम", statusIdle: "कार्ट तयार", statusPending: "कार्ट अद्ययावत होत आहे", statusVerified: "कार्ट सत्यापित", statusError: "कार्ट त्रुटी", }; case "fr": return { items: "Articles", statusIdle: "Panier pret", statusPending: "Panier en mise a jour", statusVerified: "Panier verifie", statusError: "Erreur du panier", }; case "it": return { items: "Articoli", statusIdle: "Carrello pronto", statusPending: "Carrello in aggiornamento", statusVerified: "Carrello verificato", statusError: "Errore carrello", }; case "nl": return { items: "Artikelen", statusIdle: "Winkelwagen klaar", statusPending: "Winkelwagen bijwerken", statusVerified: "Winkelwagen bevestigd", statusError: "Winkelwagen fout", }; case "sv": return { items: "Artiklar", statusIdle: "Kundvagn redo", statusPending: "Kundvagn uppdateras", statusVerified: "Kundvagn verifierad", statusError: "Kundvagn fel", }; case "pl": return { items: "Produkty", statusIdle: "Koszyk gotowy", statusPending: "Koszyk aktualizowany", statusVerified: "Koszyk potwierdzony", statusError: "Blad koszyka", }; case "da": return { items: "Varer", statusIdle: "Kurv klar", statusPending: "Kurv opdateres", statusVerified: "Kurv bekraeftet", statusError: "Kurv fejl", }; case "fi": return { items: "Tuotteet", statusIdle: "Ostoskori valmis", statusPending: "Ostoskoria paivitetaan", statusVerified: "Ostoskori vahvistettu", statusError: "Ostoskorin virhe", }; case "no": return { items: "Varer", statusIdle: "Handlekurv klar", statusPending: "Handlekurv oppdateres", statusVerified: "Handlekurv bekreftet", statusError: "Handlekurv feil", }; case "cs": return { items: "Polozky", statusIdle: "Kosik pripraven", statusPending: "Kosik se aktualizuje", statusVerified: "Kosik potvrzen", statusError: "Chyba kosiku", }; default: return { items: "Items", statusIdle: "Cart ready", statusPending: "Cart updating", statusVerified: "Cart verified", statusError: "Cart error", }; } } registerCustomElement(CartControl);