@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
711 lines (670 loc) • 18 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 { 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);