@schukai/monster 4.64.0 → 4.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
3
+ * Node module: @schukai/monster
4
+ *
5
+ * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
6
+ * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
7
+ *
8
+ * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
9
+ * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
10
+ * For more information about purchasing a commercial license, please contact Volker Schukai.
11
+ *
12
+ * SPDX-License-Identifier: AGPL-3.0
13
+ */
14
+
15
+ import { instanceSymbol } from "../../constants.mjs";
16
+ import { CustomControl } from "../../dom/customcontrol.mjs";
17
+ import {
18
+ assembleMethodSymbol,
19
+ registerCustomElement,
20
+ } from "../../dom/customelement.mjs";
21
+ import { fireCustomEvent } from "../../dom/events.mjs";
22
+ import { getLocaleOfDocument } from "../../dom/locale.mjs";
23
+ import { getDocument } from "../../dom/util.mjs";
24
+ import { Pathfinder } from "../../data/pathfinder.mjs";
25
+ import { Formatter } from "../../text/formatter.mjs";
26
+ import { isFunction, isObject, isString } from "../../types/is.mjs";
27
+ import { CommonStyleSheet } from "../stylesheet/common.mjs";
28
+ import { FormStyleSheet } from "../stylesheet/form.mjs";
29
+ import { CartControlStyleSheet } from "./stylesheet/cart-control.mjs";
30
+ import "../datatable/datasource/rest.mjs";
31
+
32
+ export { CartControl };
33
+
34
+ /**
35
+ * @private
36
+ * @type {symbol}
37
+ */
38
+ const controlElementSymbol = Symbol("controlElement");
39
+
40
+ /**
41
+ * @private
42
+ * @type {symbol}
43
+ */
44
+ const countElementSymbol = Symbol("countElement");
45
+
46
+ /**
47
+ * @private
48
+ * @type {symbol}
49
+ */
50
+ const totalElementSymbol = Symbol("totalElement");
51
+
52
+ /**
53
+ * @private
54
+ * @type {symbol}
55
+ */
56
+ const statusElementSymbol = Symbol("statusElement");
57
+
58
+ /**
59
+ * @private
60
+ * @type {symbol}
61
+ */
62
+ const datasourceElementSymbol = Symbol("datasourceElement");
63
+
64
+ /**
65
+ * @private
66
+ * @type {symbol}
67
+ */
68
+ const cartDataSymbol = Symbol("cartData");
69
+
70
+ /**
71
+ * @private
72
+ * @type {symbol}
73
+ */
74
+ const pendingPayloadSymbol = Symbol("pendingPayload");
75
+
76
+ /**
77
+ * @private
78
+ * @type {symbol}
79
+ */
80
+ const pendingRequestSymbol = Symbol("pendingRequest");
81
+
82
+ /**
83
+ * CartControl
84
+ *
85
+ * @summary Cart control that syncs with a datasource and exposes pending vs verified state.
86
+ * @fires monster-cart-control-pending
87
+ * @fires monster-cart-control-verified
88
+ * @fires monster-cart-control-error
89
+ * @fires monster-cart-control-update
90
+ */
91
+ class CartControl extends CustomControl {
92
+ static get [instanceSymbol]() {
93
+ return Symbol.for(
94
+ "@schukai/monster/components/form/cart-control@@instance",
95
+ );
96
+ }
97
+
98
+ /**
99
+ * @property {Object} templates Template definitions
100
+ * @property {string} templates.main Main template
101
+ * @property {Object} labels Labels
102
+ * @property {Object} datasource Datasource configuration
103
+ * @property {string} datasource.selector Selector for datasource
104
+ * @property {Object} datasource.rest Rest datasource config
105
+ * @property {Object} cart Cart write configuration
106
+ * @property {Object} mapping Response mapping
107
+ * @property {Object} state Control state
108
+ * @property {Object} actions Callback actions
109
+ */
110
+ get defaults() {
111
+ return Object.assign({}, super.defaults, {
112
+ templates: {
113
+ main: getTemplate(),
114
+ },
115
+ labels: getTranslations(),
116
+ datasource: {
117
+ selector: null,
118
+ rest: null,
119
+ },
120
+ cart: {
121
+ url: null,
122
+ method: "POST",
123
+ headers: {
124
+ "Content-Type": "application/json",
125
+ },
126
+ bodyTemplate: null,
127
+ },
128
+ mapping: {
129
+ selector: "*",
130
+ itemsPath: "items",
131
+ totalTemplate: null,
132
+ currencyTemplate: null,
133
+ qtyTemplate: "qty",
134
+ },
135
+ state: {
136
+ status: "idle",
137
+ },
138
+ actions: {
139
+ onpending: null,
140
+ onverified: null,
141
+ onerror: null,
142
+ onupdate: null,
143
+ },
144
+ });
145
+ }
146
+
147
+ [assembleMethodSymbol]() {
148
+ super[assembleMethodSymbol]();
149
+ initControlReferences.call(this);
150
+ initDatasource.call(this);
151
+ applyData.call(this);
152
+ setStatus.call(this, this.getOption("state.status") || "idle");
153
+ return this;
154
+ }
155
+
156
+ /**
157
+ * Re-apply mapped data from options or datasource.
158
+ * @return {CartControl}
159
+ */
160
+ refresh() {
161
+ applyData.call(this);
162
+ return this;
163
+ }
164
+
165
+ static getTag() {
166
+ return "monster-cart-control";
167
+ }
168
+
169
+ static getCSSStyleSheet() {
170
+ return [CommonStyleSheet, FormStyleSheet, CartControlStyleSheet];
171
+ }
172
+
173
+ /**
174
+ * Add or change an item in the cart.
175
+ * @param {Object} payload
176
+ */
177
+ addOrChange(payload) {
178
+ const request = buildPayload.call(this, payload);
179
+ this[pendingPayloadSymbol] = payload;
180
+ this[pendingRequestSymbol] = request;
181
+ setStatus.call(this, "pending");
182
+ fireCustomEvent(this, "monster-cart-control-pending", {
183
+ payload: request,
184
+ source: payload,
185
+ });
186
+ const action = this.getOption("actions.onpending");
187
+ if (isFunction(action)) {
188
+ action.call(this, { payload: request, source: payload });
189
+ }
190
+
191
+ if (!this[datasourceElementSymbol]) {
192
+ initDatasource.call(this);
193
+ }
194
+
195
+ if (!this[datasourceElementSymbol]) {
196
+ handleError.call(this, new Error("cart datasource not configured"));
197
+ return;
198
+ }
199
+
200
+ this[datasourceElementSymbol].data = request;
201
+ this[datasourceElementSymbol].write().catch((e) => {
202
+ handleError.call(this, e);
203
+ });
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @private
209
+ */
210
+ function initControlReferences() {
211
+ this[controlElementSymbol] = this.shadowRoot.querySelector(
212
+ "[data-monster-role=control]",
213
+ );
214
+ this[countElementSymbol] = this.shadowRoot.querySelector(
215
+ "[data-monster-role=count]",
216
+ );
217
+ this[totalElementSymbol] = this.shadowRoot.querySelector(
218
+ "[data-monster-role=total]",
219
+ );
220
+ this[statusElementSymbol] = this.shadowRoot.querySelector(
221
+ "[data-monster-role=status]",
222
+ );
223
+ }
224
+
225
+ /**
226
+ * @private
227
+ */
228
+ function initDatasource() {
229
+ if (this[datasourceElementSymbol]) {
230
+ return;
231
+ }
232
+ const selector = this.getOption("datasource.selector");
233
+ if (isString(selector) && selector !== "") {
234
+ this[datasourceElementSymbol] = getDocument().querySelector(selector);
235
+ } else if (isObject(this.getOption("datasource.rest"))) {
236
+ const rest = getDocument().createElement("monster-datasource-rest");
237
+ rest.setOption("read", this.getOption("datasource.rest.read", {}));
238
+ rest.setOption("write", this.getOption("datasource.rest.write", {}));
239
+ rest.setOption("features.autoInit", false);
240
+ this.appendChild(rest);
241
+ this[datasourceElementSymbol] = rest;
242
+ } else if (isString(this.getOption("cart.url"))) {
243
+ const rest = getDocument().createElement("monster-datasource-rest");
244
+ rest.setOption("write.url", this.getOption("cart.url"));
245
+ rest.setOption("write.method", this.getOption("cart.method"));
246
+ rest.setOption("write.init", {
247
+ method: this.getOption("cart.method"),
248
+ headers: this.getOption("cart.headers"),
249
+ });
250
+ rest.setOption("features.autoInit", false);
251
+ this.appendChild(rest);
252
+ this[datasourceElementSymbol] = rest;
253
+ }
254
+
255
+ if (this[datasourceElementSymbol]) {
256
+ this[datasourceElementSymbol].setOption(
257
+ "write.responseCallback",
258
+ (payload) => {
259
+ this[datasourceElementSymbol].data = payload;
260
+ },
261
+ );
262
+ this[datasourceElementSymbol].addEventListener(
263
+ "monster-datasource-fetched",
264
+ (event) => {
265
+ const data = event?.detail?.data || this[datasourceElementSymbol]?.data;
266
+ if (data) {
267
+ this[cartDataSymbol] = data;
268
+ applyData.call(this);
269
+ }
270
+ setStatus.call(this, "verified");
271
+ fireCustomEvent(this, "monster-cart-control-verified", {
272
+ data,
273
+ payload: this[pendingRequestSymbol],
274
+ source: this[pendingPayloadSymbol],
275
+ });
276
+ const action = this.getOption("actions.onverified");
277
+ if (isFunction(action)) {
278
+ action.call(this, {
279
+ data,
280
+ payload: this[pendingRequestSymbol],
281
+ source: this[pendingPayloadSymbol],
282
+ });
283
+ }
284
+ this[pendingPayloadSymbol] = null;
285
+ this[pendingRequestSymbol] = null;
286
+ },
287
+ );
288
+ this[datasourceElementSymbol].addEventListener(
289
+ "monster-datasource-error",
290
+ (event) => {
291
+ handleError.call(this, event?.detail?.error);
292
+ },
293
+ );
294
+ }
295
+ }
296
+
297
+ /**
298
+ * @private
299
+ */
300
+ function applyData() {
301
+ const data = this[cartDataSymbol] || this.getOption("data");
302
+ if (!data) {
303
+ return;
304
+ }
305
+
306
+ const mapping = this.getOption("mapping", {});
307
+ let source = data;
308
+ if (
309
+ isString(mapping?.selector) &&
310
+ mapping.selector !== "*" &&
311
+ mapping.selector
312
+ ) {
313
+ try {
314
+ source = new Pathfinder(data).getVia(mapping.selector);
315
+ } catch (_e) {
316
+ source = data;
317
+ }
318
+ }
319
+
320
+ const itemsPath = mapping.itemsPath;
321
+ const items = itemsPath
322
+ ? new Pathfinder(source).getVia(itemsPath)
323
+ : source?.items;
324
+ const count = Array.isArray(items)
325
+ ? items.reduce((sum, item) => {
326
+ const qty = readNumber(item, mapping.qtyTemplate) ?? 0;
327
+ return sum + qty;
328
+ }, 0)
329
+ : 0;
330
+ const total = readNumber(source, mapping.totalTemplate);
331
+ const currency = readString(source, mapping.currencyTemplate) || "EUR";
332
+
333
+ if (this[countElementSymbol]) {
334
+ setElementText(
335
+ this[countElementSymbol],
336
+ formatCount(count, this.getOption("labels.items")),
337
+ );
338
+ }
339
+ if (this[totalElementSymbol]) {
340
+ setElementText(
341
+ this[totalElementSymbol],
342
+ total !== null ? formatMoney(total, currency) : "",
343
+ );
344
+ }
345
+
346
+ fireCustomEvent(this, "monster-cart-control-update", {
347
+ count,
348
+ total,
349
+ currency,
350
+ items,
351
+ });
352
+ const action = this.getOption("actions.onupdate");
353
+ if (isFunction(action)) {
354
+ action.call(this, { count, total, currency, items });
355
+ }
356
+ }
357
+
358
+ /**
359
+ * @private
360
+ * @param {string} status
361
+ */
362
+ function setStatus(status) {
363
+ this.setOption("state.status", status);
364
+ if (!this[statusElementSymbol]) {
365
+ return;
366
+ }
367
+ let label = "";
368
+ if (status === "pending") {
369
+ label = this.getOption("labels.statusPending");
370
+ } else if (status === "verified") {
371
+ label = this.getOption("labels.statusVerified");
372
+ } else if (status === "error") {
373
+ label = this.getOption("labels.statusError");
374
+ } else {
375
+ label = this.getOption("labels.statusIdle");
376
+ }
377
+ setElementText(this[statusElementSymbol], label);
378
+ }
379
+
380
+ /**
381
+ * @private
382
+ * @param {Error} error
383
+ */
384
+ function handleError(error) {
385
+ setStatus.call(this, "error");
386
+ fireCustomEvent(this, "monster-cart-control-error", {
387
+ error,
388
+ payload: this[pendingRequestSymbol],
389
+ source: this[pendingPayloadSymbol],
390
+ });
391
+ const action = this.getOption("actions.onerror");
392
+ if (isFunction(action)) {
393
+ action.call(this, {
394
+ error,
395
+ payload: this[pendingRequestSymbol],
396
+ source: this[pendingPayloadSymbol],
397
+ });
398
+ }
399
+ this[pendingPayloadSymbol] = null;
400
+ this[pendingRequestSymbol] = null;
401
+ }
402
+
403
+ /**
404
+ * @private
405
+ * @param {Object} payload
406
+ * @return {Object}
407
+ */
408
+ function buildPayload(payload) {
409
+ if (!payload) {
410
+ return {};
411
+ }
412
+ const data = Object.assign({}, payload);
413
+ const template = this.getOption("cart.bodyTemplate");
414
+ if (isString(template) && template.includes("${")) {
415
+ const formatted = new Formatter(stringifyForTemplate(data)).format(
416
+ template,
417
+ );
418
+ try {
419
+ return JSON.parse(formatted);
420
+ } catch (_e) {
421
+ return { value: formatted };
422
+ }
423
+ }
424
+ return data;
425
+ }
426
+
427
+ /**
428
+ * @private
429
+ * @param {Object|Array|string|number|boolean|null} value
430
+ * @return {Object|Array|string}
431
+ */
432
+ function stringifyForTemplate(value) {
433
+ if (value === null || value === undefined) {
434
+ return "";
435
+ }
436
+ if (Array.isArray(value)) {
437
+ return value.map((entry) => stringifyForTemplate(entry));
438
+ }
439
+ if (typeof value === "object") {
440
+ const result = {};
441
+ for (const [key, entry] of Object.entries(value)) {
442
+ result[key] = stringifyForTemplate(entry);
443
+ }
444
+ return result;
445
+ }
446
+ return `${value}`;
447
+ }
448
+
449
+ /**
450
+ * @private
451
+ * @param {Object} source
452
+ * @param {string|null} template
453
+ * @return {string|null}
454
+ */
455
+ function readString(source, template) {
456
+ if (!isString(template) || template === "") {
457
+ return null;
458
+ }
459
+ try {
460
+ if (template.includes("${")) {
461
+ return new Formatter(source).format(template);
462
+ }
463
+ return new Pathfinder(source).getVia(template);
464
+ } catch (_e) {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * @private
471
+ * @param {Object} source
472
+ * @param {string|null} template
473
+ * @return {number|null}
474
+ */
475
+ function readNumber(source, template) {
476
+ const value = readString(source, template);
477
+ if (value === null || value === undefined) {
478
+ return null;
479
+ }
480
+ const num = Number(value);
481
+ return Number.isFinite(num) ? num : null;
482
+ }
483
+
484
+ /**
485
+ * @private
486
+ * @param {number} value
487
+ * @param {string} label
488
+ * @return {string}
489
+ */
490
+ function formatCount(value, label) {
491
+ const safeValue = Number.isFinite(value) ? value : 0;
492
+ return `${safeValue} ${label}`;
493
+ }
494
+
495
+ /**
496
+ * @private
497
+ * @param {number} value
498
+ * @param {string} currency
499
+ * @return {string}
500
+ */
501
+ function formatMoney(value, currency) {
502
+ try {
503
+ const locale = getLocaleOfDocument();
504
+ return new Intl.NumberFormat(locale.locale, {
505
+ style: "currency",
506
+ currency,
507
+ }).format(value);
508
+ } catch (_e) {
509
+ return `${value} ${currency}`;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * @private
515
+ * @param {HTMLElement} element
516
+ * @param {string} text
517
+ */
518
+ function setElementText(element, text) {
519
+ element.textContent = text;
520
+ element.hidden = text === "";
521
+ }
522
+
523
+ /**
524
+ * @private
525
+ * @return {string}
526
+ */
527
+ function getTemplate() {
528
+ // language=HTML
529
+ return `
530
+ <div data-monster-role="control" part="control">
531
+ <div data-monster-role="summary" part="summary">
532
+ <div data-monster-role="count" part="count"></div>
533
+ <div data-monster-role="total" part="total"></div>
534
+ </div>
535
+ <div data-monster-role="status" part="status"></div>
536
+ </div>
537
+ `;
538
+ }
539
+
540
+ /**
541
+ * @private
542
+ * @returns {object}
543
+ */
544
+ function getTranslations() {
545
+ const locale = getLocaleOfDocument();
546
+ switch (locale.language) {
547
+ case "de":
548
+ return {
549
+ items: "Artikel",
550
+ statusIdle: "Warenkorb bereit",
551
+ statusPending: "Warenkorb wird aktualisiert",
552
+ statusVerified: "Warenkorb bestaetigt",
553
+ statusError: "Warenkorb Fehler",
554
+ };
555
+ case "es":
556
+ return {
557
+ items: "Articulos",
558
+ statusIdle: "Carrito listo",
559
+ statusPending: "Carrito actualizando",
560
+ statusVerified: "Carrito verificado",
561
+ statusError: "Error del carrito",
562
+ };
563
+ case "zh":
564
+ return {
565
+ items: "商品",
566
+ statusIdle: "购物车已就绪",
567
+ statusPending: "购物车更新中",
568
+ statusVerified: "购物车已确认",
569
+ statusError: "购物车错误",
570
+ };
571
+ case "hi":
572
+ return {
573
+ items: "आइटम",
574
+ statusIdle: "कार्ट तैयार",
575
+ statusPending: "कार्ट अपडेट हो रहा है",
576
+ statusVerified: "कार्ट सत्यापित",
577
+ statusError: "कार्ट त्रुटि",
578
+ };
579
+ case "bn":
580
+ return {
581
+ items: "আইটেম",
582
+ statusIdle: "কার্ট প্রস্তুত",
583
+ statusPending: "কার্ট আপডেট হচ্ছে",
584
+ statusVerified: "কার্ট নিশ্চিত",
585
+ statusError: "কার্ট ত্রুটি",
586
+ };
587
+ case "pt":
588
+ return {
589
+ items: "Itens",
590
+ statusIdle: "Carrinho pronto",
591
+ statusPending: "Carrinho atualizando",
592
+ statusVerified: "Carrinho verificado",
593
+ statusError: "Erro no carrinho",
594
+ };
595
+ case "ru":
596
+ return {
597
+ items: "Tovary",
598
+ statusIdle: "Korzina gotova",
599
+ statusPending: "Korzina obnovlyaetsya",
600
+ statusVerified: "Korzina podtverzhdena",
601
+ statusError: "Oshibka korziny",
602
+ };
603
+ case "ja":
604
+ return {
605
+ items: "商品",
606
+ statusIdle: "カート準備完了",
607
+ statusPending: "カート更新中",
608
+ statusVerified: "カート確認済み",
609
+ statusError: "カートエラー",
610
+ };
611
+ case "pa":
612
+ return {
613
+ items: "ਆਈਟਮ",
614
+ statusIdle: "ਕਾਰਟ ਤਿਆਰ",
615
+ statusPending: "ਕਾਰਟ ਅੱਪਡੇਟ ਹੋ ਰਿਹਾ ਹੈ",
616
+ statusVerified: "ਕਾਰਟ ਪੁਸ਼ਟੀਤ",
617
+ statusError: "ਕਾਰਟ ਗਲਤੀ",
618
+ };
619
+ case "mr":
620
+ return {
621
+ items: "आयटम",
622
+ statusIdle: "कार्ट तयार",
623
+ statusPending: "कार्ट अद्ययावत होत आहे",
624
+ statusVerified: "कार्ट सत्यापित",
625
+ statusError: "कार्ट त्रुटी",
626
+ };
627
+ case "fr":
628
+ return {
629
+ items: "Articles",
630
+ statusIdle: "Panier pret",
631
+ statusPending: "Panier en mise a jour",
632
+ statusVerified: "Panier verifie",
633
+ statusError: "Erreur du panier",
634
+ };
635
+ case "it":
636
+ return {
637
+ items: "Articoli",
638
+ statusIdle: "Carrello pronto",
639
+ statusPending: "Carrello in aggiornamento",
640
+ statusVerified: "Carrello verificato",
641
+ statusError: "Errore carrello",
642
+ };
643
+ case "nl":
644
+ return {
645
+ items: "Artikelen",
646
+ statusIdle: "Winkelwagen klaar",
647
+ statusPending: "Winkelwagen bijwerken",
648
+ statusVerified: "Winkelwagen bevestigd",
649
+ statusError: "Winkelwagen fout",
650
+ };
651
+ case "sv":
652
+ return {
653
+ items: "Artiklar",
654
+ statusIdle: "Kundvagn redo",
655
+ statusPending: "Kundvagn uppdateras",
656
+ statusVerified: "Kundvagn verifierad",
657
+ statusError: "Kundvagn fel",
658
+ };
659
+ case "pl":
660
+ return {
661
+ items: "Produkty",
662
+ statusIdle: "Koszyk gotowy",
663
+ statusPending: "Koszyk aktualizowany",
664
+ statusVerified: "Koszyk potwierdzony",
665
+ statusError: "Blad koszyka",
666
+ };
667
+ case "da":
668
+ return {
669
+ items: "Varer",
670
+ statusIdle: "Kurv klar",
671
+ statusPending: "Kurv opdateres",
672
+ statusVerified: "Kurv bekraeftet",
673
+ statusError: "Kurv fejl",
674
+ };
675
+ case "fi":
676
+ return {
677
+ items: "Tuotteet",
678
+ statusIdle: "Ostoskori valmis",
679
+ statusPending: "Ostoskoria paivitetaan",
680
+ statusVerified: "Ostoskori vahvistettu",
681
+ statusError: "Ostoskorin virhe",
682
+ };
683
+ case "no":
684
+ return {
685
+ items: "Varer",
686
+ statusIdle: "Handlekurv klar",
687
+ statusPending: "Handlekurv oppdateres",
688
+ statusVerified: "Handlekurv bekreftet",
689
+ statusError: "Handlekurv feil",
690
+ };
691
+ case "cs":
692
+ return {
693
+ items: "Polozky",
694
+ statusIdle: "Kosik pripraven",
695
+ statusPending: "Kosik se aktualizuje",
696
+ statusVerified: "Kosik potvrzen",
697
+ statusError: "Chyba kosiku",
698
+ };
699
+ default:
700
+ return {
701
+ items: "Items",
702
+ statusIdle: "Cart ready",
703
+ statusPending: "Cart updating",
704
+ statusVerified: "Cart verified",
705
+ statusError: "Cart error",
706
+ };
707
+ }
708
+ }
709
+
710
+ registerCustomElement(CartControl);